Compare commits
5
Commits
7675e83328
...
3b76b2b23b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b76b2b23b | ||
|
|
4286a92fcc | ||
|
|
7874c79534 | ||
|
|
39c6d2bcbf | ||
|
|
e23fdcfc0d |
No files matched your search
@@ -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 можно использовать для разбора запроса или переранжирования кандидатов. Точные фильтры всё равно останутся: векторы ненадёжны для размеров, моделей и артикулов. Загрузку каталогов я сделал бы версионной: импорт во временную версию, проверка и атомарное переключение без простоя.
|
||||||
+51
-3
@@ -30,7 +30,11 @@ _VOLTAGE_RE = re.compile(
|
|||||||
rf"(?<![a-zа-я0-9-])({_NUMBER})\s*(?:v|в|вольт\w*)\b", re.IGNORECASE
|
rf"(?<![a-zа-я0-9-])({_NUMBER})\s*(?:v|в|вольт\w*)\b", re.IGNORECASE
|
||||||
)
|
)
|
||||||
_POWER_RE = re.compile(rf"\b({_NUMBER})\s*(?:w|вт|ватт\w*)\b", re.IGNORECASE)
|
_POWER_RE = re.compile(rf"\b({_NUMBER})\s*(?:w|вт|ватт\w*)\b", re.IGNORECASE)
|
||||||
_TEETH_RE = re.compile(r"\b(\d+)\s*(?:зуб(?:ьев|а|ов)?|z)\b", re.IGNORECASE)
|
_TEETH_RE = re.compile(r"\b(\d+)\s*(?:зуб(?:ьев|а|ов)?|[zt])\b", re.IGNORECASE)
|
||||||
|
_SAW_COMPACT_RE = re.compile(
|
||||||
|
rf"(?<![a-zа-я0-9.])({_NUMBER})(?:x|\s+)(\d+)\s*[zt]\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
_PACK_SIZE_RE = re.compile(
|
_PACK_SIZE_RE = re.compile(
|
||||||
r"(?:пачк\w*|упаковк\w*|\bуп\.?)(?:\s+(?:по|на))?\s*(\d+)\s*(?:шт\w*)?",
|
r"(?:пачк\w*|упаковк\w*|\bуп\.?)(?:\s+(?:по|на))?\s*(\d+)\s*(?:шт\w*)?",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
@@ -40,6 +44,7 @@ _MODEL_RE = re.compile(
|
|||||||
r"(?<![a-zа-я0-9])([a-zа-я]{2,6}-\d+[a-zа-я]?|[a-zа-я]{2,6}\d+[a-zа-я]?)(?![a-zа-я0-9])",
|
r"(?<![a-zа-я0-9])([a-zа-я]{2,6}-\d+[a-zа-я]?|[a-zа-я]{2,6}\d+[a-zа-я]?)(?![a-zа-я0-9])",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
_NORMALIZED_MODEL_RE = re.compile(r"^([a-zа-я]{2,6})(\d+)([a-zа-я]?)$", re.IGNORECASE)
|
||||||
|
|
||||||
_PAIR_DIMENSION_TYPES = frozenset(
|
_PAIR_DIMENSION_TYPES = frozenset(
|
||||||
{
|
{
|
||||||
@@ -225,6 +230,31 @@ def _extract_model_codes(normalized: str) -> frozenset[str]:
|
|||||||
return frozenset(codes)
|
return frozenset(codes)
|
||||||
|
|
||||||
|
|
||||||
|
def model_code_matches(query_code: str, candidate_code: str) -> bool:
|
||||||
|
"""Match a complete model or a model with only its final letter omitted.
|
||||||
|
|
||||||
|
Numeric parts remain exact. Thus ``арс12`` may complete to
|
||||||
|
``арс12л``, but it must not match the different model ``арс125``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if query_code == candidate_code:
|
||||||
|
return True
|
||||||
|
|
||||||
|
query_match = _NORMALIZED_MODEL_RE.fullmatch(query_code)
|
||||||
|
candidate_match = _NORMALIZED_MODEL_RE.fullmatch(candidate_code)
|
||||||
|
if query_match is None or candidate_match is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
query_prefix, query_number, query_suffix = query_match.groups()
|
||||||
|
candidate_prefix, candidate_number, candidate_suffix = candidate_match.groups()
|
||||||
|
return (
|
||||||
|
query_prefix == candidate_prefix
|
||||||
|
and query_number == candidate_number
|
||||||
|
and not query_suffix
|
||||||
|
and bool(candidate_suffix)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _detect_sds(normalized: str) -> str | None:
|
def _detect_sds(normalized: str) -> str | None:
|
||||||
flattened = normalized.replace("-", " ")
|
flattened = normalized.replace("-", " ")
|
||||||
if "sds max" in flattened or "сдс макс" in flattened:
|
if "sds max" in flattened or "сдс макс" in flattened:
|
||||||
@@ -377,8 +407,15 @@ def parse_attributes(
|
|||||||
normalized = normalize_text(text)
|
normalized = normalize_text(text)
|
||||||
dimensions = _extract_dimensions(normalized)
|
dimensions = _extract_dimensions(normalized)
|
||||||
|
|
||||||
|
compact_saw_match = _SAW_COMPACT_RE.search(normalized)
|
||||||
teeth_match = _TEETH_RE.search(normalized)
|
teeth_match = _TEETH_RE.search(normalized)
|
||||||
teeth = int(teeth_match.group(1)) if teeth_match else None
|
teeth = (
|
||||||
|
int(compact_saw_match.group(2))
|
||||||
|
if compact_saw_match is not None
|
||||||
|
else int(teeth_match.group(1))
|
||||||
|
if teeth_match is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
if not dimensions and product_type in _PAIR_DIMENSION_TYPES:
|
if not dimensions and product_type in _PAIR_DIMENSION_TYPES:
|
||||||
pair_match = _PAIR_WITH_NA_RE.search(normalized)
|
pair_match = _PAIR_WITH_NA_RE.search(normalized)
|
||||||
@@ -395,6 +432,8 @@ def parse_attributes(
|
|||||||
|
|
||||||
mm_values = [_decimal(match.group(1)) for match in _MM_RE.finditer(normalized)]
|
mm_values = [_decimal(match.group(1)) for match in _MM_RE.finditer(normalized)]
|
||||||
meter_values = [_decimal(match.group(1)) for match in _METER_RE.finditer(normalized)]
|
meter_values = [_decimal(match.group(1)) for match in _METER_RE.finditer(normalized)]
|
||||||
|
if compact_saw_match is not None:
|
||||||
|
mm_values.append(_decimal(compact_saw_match.group(1)))
|
||||||
|
|
||||||
# ``190 на 48 зубьев`` describes diameter and tooth count, not 190x48.
|
# ``190 на 48 зубьев`` describes diameter and tooth count, not 190x48.
|
||||||
saw_pair = re.search(
|
saw_pair = re.search(
|
||||||
@@ -632,7 +671,16 @@ def compare_attributes(
|
|||||||
return conflict
|
return conflict
|
||||||
|
|
||||||
if query.model_codes:
|
if query.model_codes:
|
||||||
conflict = require(query.model_codes.issubset(candidate.model_codes), "model")
|
conflict = require(
|
||||||
|
all(
|
||||||
|
any(
|
||||||
|
model_code_matches(query_code, candidate_code)
|
||||||
|
for candidate_code in candidate.model_codes
|
||||||
|
)
|
||||||
|
for query_code in query.model_codes
|
||||||
|
),
|
||||||
|
"model",
|
||||||
|
)
|
||||||
if conflict:
|
if conflict:
|
||||||
return conflict
|
return conflict
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from app.matcher import CatalogMatcher
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogItemInfo(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
sku: str
|
||||||
|
name: str
|
||||||
|
unit: str
|
||||||
|
price: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
def register_test_info_route(
|
||||||
|
application: FastAPI, matcher: CatalogMatcher
|
||||||
|
) -> None:
|
||||||
|
items_by_sku = {item.sku: item for item in matcher.items}
|
||||||
|
|
||||||
|
@application.get(
|
||||||
|
"/get_info_by/{sku}",
|
||||||
|
response_model=CatalogItemInfo,
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
def get_info_by(sku: str) -> CatalogItemInfo:
|
||||||
|
item = items_by_sku.get(sku.strip().upper())
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="SKU not found")
|
||||||
|
return CatalogItemInfo(
|
||||||
|
sku=item.sku,
|
||||||
|
name=item.name,
|
||||||
|
unit=item.unit,
|
||||||
|
price=item.price,
|
||||||
|
)
|
||||||
+37
-6
@@ -4,6 +4,7 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.matcher import CatalogMatcher
|
from app.matcher import CatalogMatcher
|
||||||
from app.models import MatchRequest, MatchResponse
|
from app.models import MatchRequest, MatchResponse
|
||||||
@@ -11,18 +12,48 @@ from app.models import MatchRequest, MatchResponse
|
|||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
CATALOG_PATH = Path(os.environ.get("CATALOG_PATH", BASE_DIR / "catalog_excel.csv"))
|
CATALOG_PATH = Path(os.environ.get("CATALOG_PATH", BASE_DIR / "catalog_excel.csv"))
|
||||||
MATCHER = CatalogMatcher(CATALOG_PATH)
|
MATCHER = CatalogMatcher(CATALOG_PATH)
|
||||||
|
TEST_INFO_ROUTE_ENV = "ENABLE_TEST_INFO_ROUTE"
|
||||||
|
|
||||||
# The task asks for one application endpoint. OpenAPI/Swagger routes are disabled
|
|
||||||
# so the running service exposes only POST /match.
|
def match(request: MatchRequest) -> MatchResponse:
|
||||||
app = FastAPI(
|
return MatchResponse(results=[MATCHER.match(message) for message in request.messages])
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(*, enable_test_info_route: bool = False) -> FastAPI:
|
||||||
|
# OpenAPI/Swagger routes are disabled so the production application exposes
|
||||||
|
# exactly the single endpoint required by the task.
|
||||||
|
application = FastAPI(
|
||||||
title="Offline catalog matcher",
|
title="Offline catalog matcher",
|
||||||
version="0.2.0",
|
version="0.2.0",
|
||||||
docs_url=None,
|
docs_url=None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
openapi_url=None,
|
openapi_url=None,
|
||||||
)
|
)
|
||||||
|
application.add_api_route(
|
||||||
|
"/match", match, methods=["POST"], response_model=MatchResponse
|
||||||
|
)
|
||||||
|
|
||||||
|
if enable_test_info_route:
|
||||||
|
from app.dev_routes import register_test_info_route
|
||||||
|
|
||||||
|
application.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["GET", "POST"],
|
||||||
|
allow_headers=["Content-Type"],
|
||||||
|
)
|
||||||
|
register_test_info_route(application, MATCHER)
|
||||||
|
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
@app.post("/match", response_model=MatchResponse)
|
def _test_info_route_enabled() -> bool:
|
||||||
def match(request: MatchRequest) -> MatchResponse:
|
return os.environ.get(TEST_INFO_ROUTE_ENV, "").strip().lower() in {
|
||||||
return MatchResponse(results=[MATCHER.match(message) for message in request.messages])
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
"on",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app(enable_test_info_route=_test_info_route_enabled())
|
||||||
+295
-10
@@ -5,10 +5,18 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.aliases import ProductAliasIndex, ProductTypeMatch
|
from app.aliases import ProductAliasIndex, ProductTypeMatch
|
||||||
from app.attributes import BrandIndex, Compatibility, compare_attributes, parse_attributes
|
from app.attributes import (
|
||||||
|
BrandIndex,
|
||||||
|
Compatibility,
|
||||||
|
ParsedAttributes,
|
||||||
|
compare_attributes,
|
||||||
|
model_code_matches,
|
||||||
|
parse_attributes,
|
||||||
|
)
|
||||||
from app.catalog import CatalogItem, build_catalog, load_raw_catalog
|
from app.catalog import CatalogItem, build_catalog, load_raw_catalog
|
||||||
from app.intents import detect_service_intent
|
from app.intents import detect_service_intent
|
||||||
from app.models import Candidate, MatchResult
|
from app.models import Candidate, MatchResult
|
||||||
|
from app.normalization import ZERO_WEIGHT_TOKENS, normalize_text, tokenize
|
||||||
from app.search_index import LexicalIndex, LexicalScore
|
from app.search_index import LexicalIndex, LexicalScore
|
||||||
|
|
||||||
|
|
||||||
@@ -20,6 +28,43 @@ class _RankedCandidate:
|
|||||||
ranking_score: float
|
ranking_score: float
|
||||||
|
|
||||||
|
|
||||||
|
_STRUCTURED_QUERY_WORDS = frozenset(
|
||||||
|
{
|
||||||
|
"x",
|
||||||
|
"max",
|
||||||
|
"plus",
|
||||||
|
"sds",
|
||||||
|
"t",
|
||||||
|
"z",
|
||||||
|
"v",
|
||||||
|
"w",
|
||||||
|
"в",
|
||||||
|
"ватт",
|
||||||
|
"ватта",
|
||||||
|
"ваттов",
|
||||||
|
"вольт",
|
||||||
|
"вольта",
|
||||||
|
"вольтов",
|
||||||
|
"зуб",
|
||||||
|
"зуба",
|
||||||
|
"зубов",
|
||||||
|
"зубьев",
|
||||||
|
"м",
|
||||||
|
"метр",
|
||||||
|
"метра",
|
||||||
|
"метров",
|
||||||
|
"миллиметр",
|
||||||
|
"миллиметра",
|
||||||
|
"миллиметров",
|
||||||
|
"мм",
|
||||||
|
"размер",
|
||||||
|
"размера",
|
||||||
|
"размером",
|
||||||
|
"вт",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CatalogMatcher:
|
class CatalogMatcher:
|
||||||
def __init__(self, catalog_path: Path) -> None:
|
def __init__(self, catalog_path: Path) -> None:
|
||||||
self.alias_index = ProductAliasIndex()
|
self.alias_index = ProductAliasIndex()
|
||||||
@@ -31,8 +76,11 @@ class CatalogMatcher:
|
|||||||
|
|
||||||
type_index: dict[str, list[int]] = defaultdict(list)
|
type_index: dict[str, list[int]] = defaultdict(list)
|
||||||
model_index: dict[str, set[int]] = defaultdict(set)
|
model_index: dict[str, set[int]] = defaultdict(set)
|
||||||
|
brand_item_index: dict[str, list[int]] = defaultdict(list)
|
||||||
for index, item in enumerate(self.items):
|
for index, item in enumerate(self.items):
|
||||||
type_index[item.product_type].append(index)
|
type_index[item.product_type].append(index)
|
||||||
|
if item.attributes.brand is not None:
|
||||||
|
brand_item_index[item.attributes.brand].append(index)
|
||||||
for model_code in item.attributes.model_codes:
|
for model_code in item.attributes.model_codes:
|
||||||
model_index[model_code].add(index)
|
model_index[model_code].add(index)
|
||||||
self.type_index = {
|
self.type_index = {
|
||||||
@@ -41,6 +89,9 @@ class CatalogMatcher:
|
|||||||
self.model_index = {
|
self.model_index = {
|
||||||
model: frozenset(indexes) for model, indexes in model_index.items()
|
model: frozenset(indexes) for model, indexes in model_index.items()
|
||||||
}
|
}
|
||||||
|
self.brand_item_index = {
|
||||||
|
brand: tuple(indexes) for brand, indexes in brand_item_index.items()
|
||||||
|
}
|
||||||
|
|
||||||
def match(self, message: str) -> MatchResult:
|
def match(self, message: str) -> MatchResult:
|
||||||
if not message or not message.strip():
|
if not message or not message.strip():
|
||||||
@@ -59,19 +110,57 @@ class CatalogMatcher:
|
|||||||
product_types = {self.items[index].product_type for index in model_candidates}
|
product_types = {self.items[index].product_type for index in model_candidates}
|
||||||
if len(product_types) == 1:
|
if len(product_types) == 1:
|
||||||
product_type = next(iter(product_types))
|
product_type = next(iter(product_types))
|
||||||
|
exact_model = all(
|
||||||
|
code in self.model_index
|
||||||
|
for code in provisional_attributes.model_codes
|
||||||
|
)
|
||||||
product_match = ProductTypeMatch(
|
product_match = ProductTypeMatch(
|
||||||
canonical=product_type,
|
canonical=product_type,
|
||||||
|
score=1.0 if exact_model else 0.93,
|
||||||
|
exact=exact_model,
|
||||||
|
alias="model_code" if exact_model else "model_code_prefix",
|
||||||
|
token_start=0,
|
||||||
|
)
|
||||||
|
if product_match is None and provisional_attributes.teeth is not None:
|
||||||
|
# A tooth count is type-specific evidence in this domain: only
|
||||||
|
# saw blades carry it. This safely supports requests such as
|
||||||
|
# "210 мм 24 зуба" without requiring the word "диск".
|
||||||
|
product_match = ProductTypeMatch(
|
||||||
|
canonical="диск",
|
||||||
score=1.0,
|
score=1.0,
|
||||||
exact=True,
|
exact=True,
|
||||||
alias="model_code",
|
alias="tooth_count",
|
||||||
token_start=0,
|
token_start=0,
|
||||||
)
|
)
|
||||||
if product_match is None:
|
if product_match is None:
|
||||||
# Product evidence always has priority. Service intent detection
|
brand_result = self._match_brand_only(message, provisional_attributes)
|
||||||
# is only a diagnostic fallback; both unknown and service
|
if brand_result is not None:
|
||||||
# messages share the required not_found response.
|
return brand_result
|
||||||
detect_service_intent(message)
|
structured_result = self._match_structured_only(
|
||||||
return MatchResult(message=message, status="not_found", candidates=[])
|
message, provisional_attributes
|
||||||
|
)
|
||||||
|
if structured_result is not None:
|
||||||
|
return structured_result
|
||||||
|
# Trigrams used to rank only inside a type selected by aliases.
|
||||||
|
# Let them infer that missing type as well, but only when the
|
||||||
|
# leading catalog rows agree and share strong word-level evidence.
|
||||||
|
# This handles "удар" -> "ударная дрель" while
|
||||||
|
# keeping numeric coincidences and arbitrary text out.
|
||||||
|
if detect_service_intent(message) is None:
|
||||||
|
lexical_inference = self.lexical_index.infer_product_type(message)
|
||||||
|
if lexical_inference is not None:
|
||||||
|
product_match = ProductTypeMatch(
|
||||||
|
canonical=lexical_inference.product_type,
|
||||||
|
score=lexical_inference.score,
|
||||||
|
exact=lexical_inference.query_word
|
||||||
|
== lexical_inference.catalog_word,
|
||||||
|
alias=lexical_inference.catalog_word,
|
||||||
|
token_start=0,
|
||||||
|
)
|
||||||
|
if product_match is None:
|
||||||
|
return MatchResult(
|
||||||
|
message=message, status="not_found", candidates=[]
|
||||||
|
)
|
||||||
|
|
||||||
product_type = product_match.canonical
|
product_type = product_match.canonical
|
||||||
query_attributes = parse_attributes(
|
query_attributes = parse_attributes(
|
||||||
@@ -132,10 +221,197 @@ class CatalogMatcher:
|
|||||||
candidates=response_candidates,
|
candidates=response_candidates,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _match_brand_only(
|
||||||
|
self, message: str, query_attributes: ParsedAttributes
|
||||||
|
) -> MatchResult | None:
|
||||||
|
if (
|
||||||
|
query_attributes.brand is None
|
||||||
|
or not query_attributes.brand_required
|
||||||
|
or query_attributes.model_codes
|
||||||
|
or query_attributes.specificity != 1
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
candidate_indices = self.brand_item_index.get(query_attributes.brand, ())
|
||||||
|
brand_match = self.brand_index.detect(message)
|
||||||
|
if not candidate_indices or brand_match is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
compatible = {
|
||||||
|
index: Compatibility(
|
||||||
|
compatible=True,
|
||||||
|
matched_fields=1,
|
||||||
|
total_fields=1,
|
||||||
|
)
|
||||||
|
for index in candidate_indices
|
||||||
|
}
|
||||||
|
lexical_scores = self.lexical_index.rank(message, "", candidate_indices)
|
||||||
|
ranked = self._apply_preferences(
|
||||||
|
lexical_scores,
|
||||||
|
compatible,
|
||||||
|
cheap=query_attributes.cheap_preference,
|
||||||
|
)
|
||||||
|
if not ranked:
|
||||||
|
return None
|
||||||
|
if not query_attributes.cheap_preference:
|
||||||
|
# With no product type every item has the same brand evidence.
|
||||||
|
# Document length must not arbitrarily favor short catalog names.
|
||||||
|
ranked.sort(key=lambda candidate: candidate.item.sku)
|
||||||
|
|
||||||
|
status = "matched" if len(ranked) == 1 else "ambiguous"
|
||||||
|
brand_signal = ProductTypeMatch(
|
||||||
|
canonical="",
|
||||||
|
score=brand_match.score,
|
||||||
|
exact=brand_match.score == 1.0,
|
||||||
|
alias=brand_match.alias,
|
||||||
|
token_start=brand_match.token_start,
|
||||||
|
)
|
||||||
|
candidates = [
|
||||||
|
Candidate(
|
||||||
|
sku=candidate.item.sku,
|
||||||
|
confidence=self._confidence(
|
||||||
|
candidate,
|
||||||
|
product_match=brand_signal,
|
||||||
|
candidate_count=len(ranked),
|
||||||
|
specificity=query_attributes.specificity,
|
||||||
|
status=status,
|
||||||
|
rank=rank,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for rank, candidate in enumerate(ranked[:3])
|
||||||
|
]
|
||||||
|
return MatchResult(message=message, status=status, candidates=candidates)
|
||||||
|
|
||||||
|
def _match_structured_only(
|
||||||
|
self, message: str, query_attributes: ParsedAttributes
|
||||||
|
) -> MatchResult | None:
|
||||||
|
if not self._is_structured_only_query(message, query_attributes):
|
||||||
|
return None
|
||||||
|
|
||||||
|
compatible: dict[int, Compatibility] = {}
|
||||||
|
for index, item in enumerate(self.items):
|
||||||
|
comparison = compare_attributes(
|
||||||
|
item.product_type,
|
||||||
|
query_attributes,
|
||||||
|
item.attributes,
|
||||||
|
candidate_unit=item.unit,
|
||||||
|
)
|
||||||
|
if comparison.compatible and comparison.matched_fields > 0:
|
||||||
|
compatible[index] = comparison
|
||||||
|
|
||||||
|
if not compatible:
|
||||||
|
return None
|
||||||
|
|
||||||
|
lexical_scores = self.lexical_index.rank(
|
||||||
|
message, "", tuple(compatible.keys())
|
||||||
|
)
|
||||||
|
ranked = self._apply_preferences(
|
||||||
|
lexical_scores,
|
||||||
|
compatible,
|
||||||
|
cheap=query_attributes.cheap_preference,
|
||||||
|
)
|
||||||
|
if not ranked:
|
||||||
|
return None
|
||||||
|
|
||||||
|
status = "matched" if len(ranked) == 1 else "ambiguous"
|
||||||
|
selected = self._select_diverse_product_types(ranked, limit=3)
|
||||||
|
structured_signal = ProductTypeMatch(
|
||||||
|
canonical="",
|
||||||
|
score=0.90,
|
||||||
|
exact=True,
|
||||||
|
alias="structured_attributes",
|
||||||
|
token_start=0,
|
||||||
|
)
|
||||||
|
candidates = [
|
||||||
|
Candidate(
|
||||||
|
sku=candidate.item.sku,
|
||||||
|
confidence=self._confidence(
|
||||||
|
candidate,
|
||||||
|
product_match=structured_signal,
|
||||||
|
candidate_count=len(ranked),
|
||||||
|
specificity=query_attributes.specificity,
|
||||||
|
status=status,
|
||||||
|
rank=rank,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for rank, candidate in enumerate(selected)
|
||||||
|
]
|
||||||
|
return MatchResult(message=message, status=status, candidates=candidates)
|
||||||
|
|
||||||
|
def _is_structured_only_query(
|
||||||
|
self, message: str, query_attributes: ParsedAttributes
|
||||||
|
) -> bool:
|
||||||
|
has_strong_evidence = any(
|
||||||
|
(
|
||||||
|
bool(query_attributes.dimensions),
|
||||||
|
bool(query_attributes.mm_values),
|
||||||
|
bool(query_attributes.meter_values),
|
||||||
|
query_attributes.thread_size is not None,
|
||||||
|
query_attributes.profile is not None,
|
||||||
|
query_attributes.grit is not None,
|
||||||
|
query_attributes.voltage_v is not None,
|
||||||
|
query_attributes.power_w is not None,
|
||||||
|
query_attributes.teeth is not None,
|
||||||
|
query_attributes.brand is not None
|
||||||
|
and query_attributes.brand_required,
|
||||||
|
bool(query_attributes.model_codes),
|
||||||
|
query_attributes.sds_type is not None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not has_strong_evidence:
|
||||||
|
return False
|
||||||
|
|
||||||
|
allowed_words = set(ZERO_WEIGHT_TOKENS) | set(_STRUCTURED_QUERY_WORDS)
|
||||||
|
brand_match = self.brand_index.detect(message)
|
||||||
|
if brand_match is not None:
|
||||||
|
allowed_words.update(tokenize(brand_match.alias))
|
||||||
|
|
||||||
|
for token in tokenize(normalize_text(message), already_normalized=True):
|
||||||
|
if any(character.isdigit() for character in token):
|
||||||
|
continue
|
||||||
|
if token in allowed_words:
|
||||||
|
continue
|
||||||
|
if token.startswith(("бюджет", "дешев", "недорог")):
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _select_diverse_product_types(
|
||||||
|
ranked: list[_RankedCandidate], *, limit: int
|
||||||
|
) -> list[_RankedCandidate]:
|
||||||
|
if len({candidate.item.product_type for candidate in ranked}) == 1:
|
||||||
|
return ranked[:limit]
|
||||||
|
|
||||||
|
selected: list[_RankedCandidate] = []
|
||||||
|
product_types: set[str] = set()
|
||||||
|
|
||||||
|
for candidate in ranked:
|
||||||
|
if candidate.item.product_type in product_types:
|
||||||
|
continue
|
||||||
|
selected.append(candidate)
|
||||||
|
product_types.add(candidate.item.product_type)
|
||||||
|
if len(selected) == limit:
|
||||||
|
break
|
||||||
|
return selected
|
||||||
|
|
||||||
def _model_candidates(self, model_codes: frozenset[str]) -> frozenset[int]:
|
def _model_candidates(self, model_codes: frozenset[str]) -> frozenset[int]:
|
||||||
if not model_codes:
|
if not model_codes:
|
||||||
return frozenset()
|
return frozenset()
|
||||||
sets = [self.model_index.get(code, frozenset()) for code in model_codes]
|
|
||||||
|
sets: list[frozenset[int]] = []
|
||||||
|
for query_code in model_codes:
|
||||||
|
exact = self.model_index.get(query_code)
|
||||||
|
if exact:
|
||||||
|
sets.append(exact)
|
||||||
|
continue
|
||||||
|
|
||||||
|
partial_indexes: set[int] = set()
|
||||||
|
for candidate_code, indexes in self.model_index.items():
|
||||||
|
if model_code_matches(query_code, candidate_code):
|
||||||
|
partial_indexes.update(indexes)
|
||||||
|
sets.append(frozenset(partial_indexes))
|
||||||
|
|
||||||
if not sets or any(not indexes for indexes in sets):
|
if not sets or any(not indexes for indexes in sets):
|
||||||
return frozenset()
|
return frozenset()
|
||||||
result = set(sets[0])
|
result = set(sets[0])
|
||||||
@@ -167,7 +443,7 @@ class CatalogMatcher:
|
|||||||
if price_range == 0
|
if price_range == 0
|
||||||
else float((maximum_price - item.price) / price_range)
|
else float((maximum_price - item.price) / price_range)
|
||||||
)
|
)
|
||||||
ranking_score = lexical.score * 0.68 + cheap_score * 0.32
|
ranking_score = cheap_score
|
||||||
else:
|
else:
|
||||||
ranking_score = lexical.score
|
ranking_score = lexical.score
|
||||||
ranked.append(
|
ranked.append(
|
||||||
@@ -179,10 +455,19 @@ class CatalogMatcher:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if cheap:
|
||||||
|
ranked.sort(
|
||||||
|
key=lambda candidate: (
|
||||||
|
candidate.item.price,
|
||||||
|
-candidate.lexical.score,
|
||||||
|
candidate.item.sku,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
ranked.sort(
|
ranked.sort(
|
||||||
key=lambda candidate: (
|
key=lambda candidate: (
|
||||||
-candidate.ranking_score,
|
-candidate.ranking_score,
|
||||||
candidate.item.price if cheap else candidate.item.sku,
|
candidate.item.sku,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return ranked
|
return ranked
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
class MatchRequest(BaseModel):
|
class MatchRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
messages: list[str] = Field(default_factory=list)
|
messages: list[str]
|
||||||
|
|
||||||
|
|
||||||
class Candidate(BaseModel):
|
class Candidate(BaseModel):
|
||||||
|
|||||||
+95
-1
@@ -6,7 +6,12 @@ from dataclasses import dataclass
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from app.normalization import meaningful_tokens, normalize_text, tokenize, word_tokens
|
from app.normalization import meaningful_tokens, normalize_text, tokenize, word_tokens
|
||||||
from app.typo import character_trigrams, trigram_dice, word_similarity
|
from app.typo import (
|
||||||
|
character_trigrams,
|
||||||
|
damerau_levenshtein_distance,
|
||||||
|
trigram_dice,
|
||||||
|
word_similarity,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.catalog import CatalogItem
|
from app.catalog import CatalogItem
|
||||||
@@ -22,6 +27,14 @@ class LexicalScore:
|
|||||||
exact_coverage: float
|
exact_coverage: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ProductTypeInference:
|
||||||
|
product_type: str
|
||||||
|
score: float
|
||||||
|
query_word: str
|
||||||
|
catalog_word: str
|
||||||
|
|
||||||
|
|
||||||
class LexicalIndex:
|
class LexicalIndex:
|
||||||
"""Small in-memory lexical index: BM25 + char trigrams + typo alignment."""
|
"""Small in-memory lexical index: BM25 + char trigrams + typo alignment."""
|
||||||
|
|
||||||
@@ -69,6 +82,87 @@ class LexicalIndex:
|
|||||||
counts.update(self._trigram_postings.get(trigram, ()))
|
counts.update(self._trigram_postings.get(trigram, ()))
|
||||||
return tuple(index for index, _ in counts.most_common(limit))
|
return tuple(index for index, _ in counts.most_common(limit))
|
||||||
|
|
||||||
|
def infer_product_type(self, query_text: str) -> ProductTypeInference | None:
|
||||||
|
"""Infer a missing product type from stable lexical evidence.
|
||||||
|
|
||||||
|
Trigrams provide the shortlist, but are deliberately not sufficient on
|
||||||
|
their own: sizes and random strings can share a few character triples
|
||||||
|
with unrelated catalog rows. The leading rows must agree on one type
|
||||||
|
and share a catalog word that is an exact match, a morphological prefix,
|
||||||
|
or a tightly bounded typo of a query word.
|
||||||
|
"""
|
||||||
|
|
||||||
|
candidate_indices = self.trigram_candidates(query_text, limit=60)
|
||||||
|
if len(candidate_indices) < 3:
|
||||||
|
return None
|
||||||
|
|
||||||
|
leaders = self.rank(query_text, "", candidate_indices)[:3]
|
||||||
|
if len(leaders) < 3:
|
||||||
|
return None
|
||||||
|
|
||||||
|
product_types = {
|
||||||
|
self._items[leader.item_index].product_type for leader in leaders
|
||||||
|
}
|
||||||
|
if len(product_types) != 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
common_words = set(self._document_words[leaders[0].item_index])
|
||||||
|
for leader in leaders[1:]:
|
||||||
|
common_words.intersection_update(self._document_words[leader.item_index])
|
||||||
|
if not common_words:
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized = normalize_text(query_text)
|
||||||
|
query_words = word_tokens(
|
||||||
|
meaningful_tokens(tokenize(normalized, already_normalized=True))
|
||||||
|
)
|
||||||
|
best: tuple[float, str, str] | None = None
|
||||||
|
for query_word in query_words:
|
||||||
|
if len(query_word) < 4:
|
||||||
|
continue
|
||||||
|
for catalog_word in common_words:
|
||||||
|
score = self._type_inference_word_score(query_word, catalog_word)
|
||||||
|
if score <= 0:
|
||||||
|
continue
|
||||||
|
evidence = (score, query_word, catalog_word)
|
||||||
|
if best is None or evidence > best:
|
||||||
|
best = evidence
|
||||||
|
|
||||||
|
if best is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
score, query_word, catalog_word = best
|
||||||
|
return ProductTypeInference(
|
||||||
|
product_type=next(iter(product_types)),
|
||||||
|
score=score,
|
||||||
|
query_word=query_word,
|
||||||
|
catalog_word=catalog_word,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _type_inference_word_score(query_word: str, catalog_word: str) -> float:
|
||||||
|
if query_word == catalog_word:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
shorter_length = min(len(query_word), len(catalog_word))
|
||||||
|
longer_length = max(len(query_word), len(catalog_word))
|
||||||
|
if shorter_length >= 4 and (
|
||||||
|
query_word.startswith(catalog_word)
|
||||||
|
or catalog_word.startswith(query_word)
|
||||||
|
):
|
||||||
|
length_ratio = shorter_length / longer_length
|
||||||
|
if length_ratio >= 0.55:
|
||||||
|
return 0.72 + length_ratio * 0.18
|
||||||
|
|
||||||
|
distance = damerau_levenshtein_distance(query_word, catalog_word)
|
||||||
|
if shorter_length >= 5 and distance <= 1:
|
||||||
|
return 0.90
|
||||||
|
if longer_length >= 8 and distance <= 2:
|
||||||
|
edit_similarity = 1.0 - distance / longer_length
|
||||||
|
if edit_similarity >= 0.75:
|
||||||
|
return 0.82
|
||||||
|
return 0.0
|
||||||
|
|
||||||
def rank(
|
def rank(
|
||||||
self,
|
self,
|
||||||
query_text: str,
|
query_text: str,
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
добрый день, саморезы по дереву 3.5х25 пачка 200 штук
|
||||||
|
саморез гкл 3,5 на 25 кг
|
||||||
|
нужны черные саморезы 4.2x75 упаковка 1000
|
||||||
|
самарезы па дереву 3.5х45 200шт
|
||||||
|
саморезы 99х999
|
||||||
|
дюбель гвоздь 6x40
|
||||||
|
дюбеля 8 на 80 есть?
|
||||||
|
анкерный болт м10
|
||||||
|
гайка м8 оцинкованная
|
||||||
|
шайбы м12 сколько стоят
|
||||||
|
болт м6х30 50 штук
|
||||||
|
шпилька м10 один метр
|
||||||
|
крепеж какой-нибудь
|
||||||
|
кабель ввг нг ls 3*2,5
|
||||||
|
провод пвс 2х1.5 нужен
|
||||||
|
ШВВП 2 X 0,75, цена за метр?
|
||||||
|
кабель кг 3х2,5
|
||||||
|
кабель для чайника
|
||||||
|
3х1.5
|
||||||
|
ввгнг 20х20
|
||||||
|
труба профильная 40x20x2
|
||||||
|
профтруба 60 40 3
|
||||||
|
труба квадратная двадцать на двадцать
|
||||||
|
труба 35х35х2
|
||||||
|
нужна труба длиной до луны
|
||||||
|
гипсокартон 12,5 влагостойкий
|
||||||
|
гкл обычный 9.5 мм
|
||||||
|
лист гипса зеленый
|
||||||
|
гипсокортан 12.5
|
||||||
|
фанера 18 мм
|
||||||
|
дрель prowerk pw-750
|
||||||
|
аккумуляторная дрель 18в
|
||||||
|
шуруповёрт 12 вольт самый дешёвый
|
||||||
|
перфоратор sds plus 800 вт
|
||||||
|
болгарка 125
|
||||||
|
ушм 230 мм 2200 вт
|
||||||
|
лобзик посоветуйте
|
||||||
|
makita df333 есть?
|
||||||
|
инструмент чтобы сверлить стену
|
||||||
|
дрэл 9999 ват
|
||||||
|
бур sds-plus 8x160
|
||||||
|
бур 10 на 210 sds+
|
||||||
|
бур SDS MAX 20х400
|
||||||
|
сверло по металлу 6 мм
|
||||||
|
сверло по дереву 10x120
|
||||||
|
бита ph2 25
|
||||||
|
набор бит torx
|
||||||
|
бита t30 50 мм
|
||||||
|
бита t99
|
||||||
|
насадка на шуруповерт
|
||||||
|
диск отрезной 125х1,0 металл
|
||||||
|
круг по металлу 230 на 2.5
|
||||||
|
пильный диск 190x48t
|
||||||
|
алмазный диск 125 бетон
|
||||||
|
диск на болгарку
|
||||||
|
круг квадратный 12х34
|
||||||
|
наждачная бумага p80
|
||||||
|
шкурка нулёвка
|
||||||
|
круг лепестковый 125 р40
|
||||||
|
лента фум 19 мм
|
||||||
|
изолента синяя 15 мм
|
||||||
|
хомут 4,8x300 черный
|
||||||
|
стяжки пластиковые
|
||||||
|
перчатки нитриловые размер l
|
||||||
|
перчатки хб с пвх
|
||||||
|
мешки для мусора 120 литров
|
||||||
|
пена монтажная зимняя
|
||||||
|
герметик силиконовый прозрачный
|
||||||
|
клей жидкие гвозди
|
||||||
|
WD-40 400 мл
|
||||||
|
здравствуйте
|
||||||
|
вы сегодня открыты?
|
||||||
|
какой адрес
|
||||||
|
есть доставка в химки?
|
||||||
|
можно вернуть товар без чека
|
||||||
|
примите заказ 12345
|
||||||
|
где мой заказ 000001
|
||||||
|
оплатил, но денег нет
|
||||||
|
спасибо большое!
|
||||||
|
позовите человека
|
||||||
|
1234567890
|
||||||
|
000000000000000000
|
||||||
|
42
|
||||||
|
qwerty asdf zxcv
|
||||||
|
йцукен фывапр олдж
|
||||||
|
абракадабра крокодил вертолёт
|
||||||
|
!@#$%^&*()
|
||||||
|
..........
|
||||||
|
купить купить купить
|
||||||
|
null
|
||||||
|
None
|
||||||
|
SELECT * FROM catalog;
|
||||||
|
<script>alert(1)</script>
|
||||||
|
../../etc/passwd
|
||||||
|
🤖🔩🔨
|
||||||
|
САМОБРЕЗЫ ГКЛ 3.5Х25???
|
||||||
|
ну эээ диск этот круглый короче
|
||||||
|
мне два метра того красного
|
||||||
|
хочу всё и сразу за сто рублей
|
||||||
|
статус заказа 4512 и бур 8х160
|
||||||
|
диск
|
||||||
|
24 зуба
|
||||||
|
210 мм 24 зуба
|
||||||
|
125 мм
|
||||||
|
12 в
|
||||||
|
900 вт
|
||||||
|
M10
|
||||||
|
P120
|
||||||
|
SDS-plus
|
||||||
|
PH2
|
||||||
|
20x20x2
|
||||||
|
ToolKraft 125 мм
|
||||||
|
99999 мм
|
||||||
|
аквариум 125 мм
|
||||||
|
удар
|
||||||
|
ударная
|
||||||
|
удар мощный
|
||||||
|
удар мощно 900 хромированый анус долбить
|
||||||
|
дрель удар мощно 900 хромированый анус долбить
|
||||||
|
анус
|
||||||
|
долбить
|
||||||
|
Арс-12
|
||||||
+70
-1
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.main import app
|
from app.main import app, create_app
|
||||||
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
||||||
@@ -38,12 +38,81 @@ def test_match_contract_and_message_order() -> None:
|
|||||||
assert 0.0 <= candidate["confidence"] <= 1.0
|
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:
|
def test_empty_message_list_is_valid() -> None:
|
||||||
response = client.post("/match", json={"messages": []})
|
response = client.post("/match", json={"messages": []})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {"results": []}
|
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:
|
def test_unknown_request_fields_are_rejected() -> None:
|
||||||
response = client.post("/match", json={"messages": [], "debug": True})
|
response = client.post("/match", json={"messages": [], "debug": True})
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_opt_in_info_route_returns_catalog_item() -> None:
|
||||||
|
test_app = create_app(enable_test_info_route=True)
|
||||||
|
test_client = TestClient(test_app)
|
||||||
|
|
||||||
|
assert [route.path for route in test_app.routes] == [
|
||||||
|
"/match",
|
||||||
|
"/get_info_by/{sku}",
|
||||||
|
]
|
||||||
|
|
||||||
|
response = test_client.get("/get_info_by/bit-0073")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert set(response.json()) == {"sku", "name", "unit", "price"}
|
||||||
|
assert response.json()["sku"] == "BIT-0073"
|
||||||
|
assert response.json()["name"]
|
||||||
|
assert response.json()["unit"]
|
||||||
|
assert float(response.json()["price"]) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_opt_in_info_route_returns_404_for_unknown_sku() -> None:
|
||||||
|
test_client = TestClient(create_app(enable_test_info_route=True))
|
||||||
|
|
||||||
|
response = test_client.get("/get_info_by/UNKNOWN-0000")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json() == {"detail": "SKU not found"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_opt_in_routes_allow_requests_from_local_html_page() -> None:
|
||||||
|
test_client = TestClient(create_app(enable_test_info_route=True))
|
||||||
|
response = test_client.options(
|
||||||
|
"/match",
|
||||||
|
headers={
|
||||||
|
"Origin": "null",
|
||||||
|
"Access-Control-Request-Method": "POST",
|
||||||
|
"Access-Control-Request-Headers": "content-type",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["access-control-allow-origin"] == "*"
|
||||||
|
assert "POST" in response.headers["access-control-allow-methods"]
|
||||||
|
assert "GET" in response.headers["access-control-allow-methods"]
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.catalog import CatalogItem, RawCatalogItem, load_raw_catalog
|
||||||
|
from app.matcher import CatalogMatcher
|
||||||
|
|
||||||
|
|
||||||
|
CATALOG_ITEMS = load_raw_catalog(
|
||||||
|
Path(__file__).resolve().parents[1] / "catalog_excel.csv"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("item", CATALOG_ITEMS, ids=lambda item: item.sku)
|
||||||
|
def test_full_catalog_name_retrieves_its_own_sku(
|
||||||
|
matcher: CatalogMatcher, item: RawCatalogItem
|
||||||
|
) -> None:
|
||||||
|
result = matcher.match(item.name)
|
||||||
|
|
||||||
|
assert item.sku in {candidate.sku for candidate in result.candidates}
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_canonical_product_type_returns_only_its_own_items(
|
||||||
|
matcher: CatalogMatcher,
|
||||||
|
) -> None:
|
||||||
|
items_by_sku = {item.sku: item for item in matcher.items}
|
||||||
|
product_types = {item.product_type for item in matcher.items}
|
||||||
|
|
||||||
|
for product_type in product_types:
|
||||||
|
result = matcher.match(product_type)
|
||||||
|
|
||||||
|
assert result.candidates, product_type
|
||||||
|
assert all(
|
||||||
|
items_by_sku[candidate.sku].product_type == product_type
|
||||||
|
for candidate in result.candidates
|
||||||
|
), product_type
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_catalog_model_code_returns_only_matching_items(
|
||||||
|
matcher: CatalogMatcher,
|
||||||
|
) -> None:
|
||||||
|
items_by_sku = {item.sku: item for item in matcher.items}
|
||||||
|
model_codes = {
|
||||||
|
model
|
||||||
|
for item in matcher.items
|
||||||
|
for model in item.attributes.model_codes
|
||||||
|
}
|
||||||
|
|
||||||
|
for model_code in model_codes:
|
||||||
|
result = matcher.match(model_code)
|
||||||
|
|
||||||
|
assert result.candidates, model_code
|
||||||
|
assert all(
|
||||||
|
model_code in items_by_sku[candidate.sku].attributes.model_codes
|
||||||
|
for candidate in result.candidates
|
||||||
|
), model_code
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_catalog_brand_returns_only_its_own_items(
|
||||||
|
matcher: CatalogMatcher,
|
||||||
|
) -> None:
|
||||||
|
items_by_sku = {item.sku: item for item in matcher.items}
|
||||||
|
brands = {
|
||||||
|
item.attributes.brand
|
||||||
|
for item in matcher.items
|
||||||
|
if item.attributes.brand is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
for brand in brands:
|
||||||
|
result = matcher.match(brand)
|
||||||
|
|
||||||
|
assert result.candidates, brand
|
||||||
|
assert all(
|
||||||
|
items_by_sku[candidate.sku].attributes.brand == brand
|
||||||
|
for candidate in result.candidates
|
||||||
|
), brand
|
||||||
|
|
||||||
|
|
||||||
|
def _number_text(value: Decimal) -> str:
|
||||||
|
return format(value, "f")
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_query_returns_only(
|
||||||
|
matcher: CatalogMatcher,
|
||||||
|
query: str,
|
||||||
|
predicate: Callable[[CatalogItem], bool],
|
||||||
|
) -> None:
|
||||||
|
items_by_sku = {item.sku: item for item in matcher.items}
|
||||||
|
result = matcher.match(query)
|
||||||
|
|
||||||
|
assert result.candidates, query
|
||||||
|
assert all(
|
||||||
|
predicate(items_by_sku[candidate.sku]) for candidate in result.candidates
|
||||||
|
), query
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_catalog_millimetre_value_is_searchable_without_product_name(
|
||||||
|
matcher: CatalogMatcher,
|
||||||
|
) -> None:
|
||||||
|
values = {
|
||||||
|
value
|
||||||
|
for item in matcher.items
|
||||||
|
for value in (
|
||||||
|
*item.attributes.mm_values,
|
||||||
|
*((item.attributes.dimensions[0],) if item.attributes.dimensions else ()),
|
||||||
|
*((item.attributes.thread_length_mm,) if item.attributes.thread_length_mm else ()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for value in values:
|
||||||
|
_assert_query_returns_only(
|
||||||
|
matcher,
|
||||||
|
f"{_number_text(value)} мм",
|
||||||
|
lambda item, expected=value: (
|
||||||
|
expected in item.attributes.mm_values
|
||||||
|
or bool(
|
||||||
|
item.attributes.dimensions
|
||||||
|
and item.attributes.dimensions[0] == expected
|
||||||
|
)
|
||||||
|
or item.attributes.thread_length_mm == expected
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_catalog_dimension_is_searchable_without_product_name(
|
||||||
|
matcher: CatalogMatcher,
|
||||||
|
) -> None:
|
||||||
|
dimensions = {
|
||||||
|
item.attributes.dimensions
|
||||||
|
for item in matcher.items
|
||||||
|
if item.attributes.dimensions
|
||||||
|
}
|
||||||
|
|
||||||
|
for dimension in dimensions:
|
||||||
|
query = "x".join(_number_text(value) for value in dimension)
|
||||||
|
_assert_query_returns_only(
|
||||||
|
matcher,
|
||||||
|
query,
|
||||||
|
lambda item, expected=dimension: (
|
||||||
|
len(item.attributes.dimensions) >= len(expected)
|
||||||
|
and item.attributes.dimensions[: len(expected)] == expected
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field", "suffix"),
|
||||||
|
[
|
||||||
|
("voltage_v", " В"),
|
||||||
|
("power_w", " Вт"),
|
||||||
|
("thread_size", ""),
|
||||||
|
("grit", ""),
|
||||||
|
("profile", ""),
|
||||||
|
("sds_type", ""),
|
||||||
|
("teeth", " зубьев"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_every_scalar_catalog_attribute_is_searchable_without_product_name(
|
||||||
|
matcher: CatalogMatcher,
|
||||||
|
field: str,
|
||||||
|
suffix: str,
|
||||||
|
) -> None:
|
||||||
|
values = {
|
||||||
|
getattr(item.attributes, field)
|
||||||
|
for item in matcher.items
|
||||||
|
if getattr(item.attributes, field) is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
for value in values:
|
||||||
|
if field == "thread_size":
|
||||||
|
query = f"M{_number_text(value)}"
|
||||||
|
elif field == "grit":
|
||||||
|
query = f"P{value}"
|
||||||
|
else:
|
||||||
|
value_text = _number_text(value) if isinstance(value, Decimal) else str(value)
|
||||||
|
query = f"{value_text}{suffix}"
|
||||||
|
_assert_query_returns_only(
|
||||||
|
matcher,
|
||||||
|
query,
|
||||||
|
lambda item, expected=value: getattr(item.attributes, field) == expected,
|
||||||
|
)
|
||||||
@@ -126,6 +126,105 @@ def test_product_evidence_has_priority_over_service_words(matcher: CatalogMatche
|
|||||||
assert result.candidates[0].sku == "BIT-0077"
|
assert result.candidates[0].sku == "BIT-0077"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("brand", ["makita", "макита", "макиты", "макиту"])
|
||||||
|
def test_makita_transliterations_share_the_same_brand_alias(
|
||||||
|
matcher: CatalogMatcher, brand: str
|
||||||
|
) -> None:
|
||||||
|
result = matcher.match(f"шуруповерт как у {brand}, только дешевле")
|
||||||
|
|
||||||
|
assert result.status == "ambiguous"
|
||||||
|
assert [candidate.sku for candidate in result.candidates] == [
|
||||||
|
"INS-0017",
|
||||||
|
"INS-0010",
|
||||||
|
"INS-0003",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cheap_preference_orders_candidates_by_ascending_price(
|
||||||
|
matcher: CatalogMatcher,
|
||||||
|
) -> None:
|
||||||
|
result = matcher.match("шуруповерт как у макиты, только дешевле")
|
||||||
|
prices_by_sku = {item.sku: item.price for item in matcher.items}
|
||||||
|
candidate_prices = [
|
||||||
|
prices_by_sku[candidate.sku] for candidate in result.candidates
|
||||||
|
]
|
||||||
|
|
||||||
|
assert candidate_prices == sorted(candidate_prices)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"brand", ["ToolKraft", "toolkraft", "тулкрафт", "тул крафт"]
|
||||||
|
)
|
||||||
|
def test_brand_only_toolkraft_query_returns_catalog_items(
|
||||||
|
matcher: CatalogMatcher, brand: str
|
||||||
|
) -> None:
|
||||||
|
result = matcher.match(brand)
|
||||||
|
|
||||||
|
assert result.status == "ambiguous"
|
||||||
|
assert [candidate.sku for candidate in result.candidates] == [
|
||||||
|
"INS-0022",
|
||||||
|
"INS-0023",
|
||||||
|
"INS-0024",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_toolkraft_model_does_not_fall_back_to_brand_results(
|
||||||
|
matcher: CatalogMatcher,
|
||||||
|
) -> None:
|
||||||
|
result = matcher.match("ToolKraft TK-999")
|
||||||
|
|
||||||
|
assert result.status == "not_found"
|
||||||
|
assert result.candidates == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("query", "sku"),
|
||||||
|
[
|
||||||
|
("Арс-12", "INS-0017"),
|
||||||
|
("АРС12", "INS-0017"),
|
||||||
|
("АРС-18", "INS-0018"),
|
||||||
|
("PW-12", "INS-0010"),
|
||||||
|
("TK-12", "INS-0024"),
|
||||||
|
("ТР-12", "INS-0003"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_model_code_can_omit_only_its_final_letter(
|
||||||
|
matcher: CatalogMatcher, query: str, sku: str
|
||||||
|
) -> None:
|
||||||
|
result = matcher.match(query)
|
||||||
|
|
||||||
|
assert result.status == "matched"
|
||||||
|
assert [candidate.sku for candidate in result.candidates] == [sku]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"query",
|
||||||
|
[
|
||||||
|
"АРС-1",
|
||||||
|
"АРС-999",
|
||||||
|
"PW-90",
|
||||||
|
"TK-999",
|
||||||
|
"УШМ АРС-12",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_partial_model_does_not_guess_numeric_or_conflicting_models(
|
||||||
|
matcher: CatalogMatcher, query: str
|
||||||
|
) -> None:
|
||||||
|
result = matcher.match(query)
|
||||||
|
|
||||||
|
assert result.status == "not_found"
|
||||||
|
assert result.candidates == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_neighboring_model_keeps_exact_priority(
|
||||||
|
matcher: CatalogMatcher,
|
||||||
|
) -> None:
|
||||||
|
result = matcher.match("АРС-125")
|
||||||
|
|
||||||
|
assert result.status == "matched"
|
||||||
|
assert [candidate.sku for candidate in result.candidates] == ["INS-0020"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("query", ["шурпуоверт на 12в", "шураыввавёрт на 12в"])
|
@pytest.mark.parametrize("query", ["шурпуоверт на 12в", "шураыввавёрт на 12в"])
|
||||||
def test_product_type_survives_typos(matcher: CatalogMatcher, query: str) -> None:
|
def test_product_type_survives_typos(matcher: CatalogMatcher, query: str) -> None:
|
||||||
result = matcher.match(query)
|
result = matcher.match(query)
|
||||||
@@ -133,6 +232,55 @@ def test_product_type_survives_typos(matcher: CatalogMatcher, query: str) -> Non
|
|||||||
assert all(candidate.sku.startswith("INS-") for candidate in result.candidates)
|
assert all(candidate.sku.startswith("INS-") for candidate in result.candidates)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("query", "skus"),
|
||||||
|
[
|
||||||
|
("удар", ("INS-0008", "INS-0009", "INS-0022")),
|
||||||
|
("ударная", ("INS-0008", "INS-0009", "INS-0022")),
|
||||||
|
("удар мощный", ("INS-0008", "INS-0009", "INS-0022")),
|
||||||
|
(
|
||||||
|
"удар мощно 900 хромированый анус долбить",
|
||||||
|
("INS-0009", "INS-0016", "INS-0023"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"дрель удар мощно 900 хромированый анус долбить",
|
||||||
|
("INS-0009", "INS-0023", "INS-0002"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_trigram_fallback_finds_product_type_from_sparse_or_noisy_text(
|
||||||
|
matcher: CatalogMatcher, query: str, skus: tuple[str, ...]
|
||||||
|
) -> None:
|
||||||
|
result = matcher.match(query)
|
||||||
|
|
||||||
|
assert result.status == "ambiguous"
|
||||||
|
assert tuple(candidate.sku for candidate in result.candidates) == skus
|
||||||
|
items_by_sku = {item.sku: item for item in matcher.items}
|
||||||
|
assert {
|
||||||
|
items_by_sku[candidate.sku].product_type for candidate in result.candidates
|
||||||
|
} == {"дрель"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"query",
|
||||||
|
[
|
||||||
|
"анус",
|
||||||
|
"долбить",
|
||||||
|
"какой адрес",
|
||||||
|
"аквариум 125 мм",
|
||||||
|
"<script>alert(1)</script>",
|
||||||
|
"qwerty asdf zxcv",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_trigram_fallback_rejects_weak_or_accidental_similarity(
|
||||||
|
matcher: CatalogMatcher, query: str
|
||||||
|
) -> None:
|
||||||
|
result = matcher.match(query)
|
||||||
|
|
||||||
|
assert result.status == "not_found"
|
||||||
|
assert result.candidates == []
|
||||||
|
|
||||||
|
|
||||||
def test_bare_size_is_parsed_without_unit(matcher: CatalogMatcher) -> None:
|
def test_bare_size_is_parsed_without_unit(matcher: CatalogMatcher) -> None:
|
||||||
assert matcher.match("бита ph2 50").candidates[0].sku == "BIT-0005"
|
assert matcher.match("бита ph2 50").candidates[0].sku == "BIT-0005"
|
||||||
assert matcher.match("диск пильный 190 48 зубьев").candidates[0].sku == "DSK-0034"
|
assert matcher.match("диск пильный 190 48 зубьев").candidates[0].sku == "DSK-0034"
|
||||||
@@ -144,3 +292,110 @@ def test_unknown_size_does_not_fall_back_to_nearest_product(
|
|||||||
result = matcher.match("ушм 150")
|
result = matcher.match("ушм 150")
|
||||||
assert result.status == "not_found"
|
assert result.status == "not_found"
|
||||||
assert result.candidates == []
|
assert result.candidates == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("query", "sku"),
|
||||||
|
[
|
||||||
|
("165 мм 24 зуба", "DSK-0029"),
|
||||||
|
("165 мм 36 зубов", "DSK-0030"),
|
||||||
|
("165 мм 48 зубьев", "DSK-0031"),
|
||||||
|
("190 мм 24 зуба", "DSK-0032"),
|
||||||
|
("190 мм 36 зубов", "DSK-0033"),
|
||||||
|
("190 мм 48 зубьев", "DSK-0034"),
|
||||||
|
("210 мм 24 зуба", "DSK-0035"),
|
||||||
|
("210 мм 36 зубов", "DSK-0036"),
|
||||||
|
("210 мм 48 зубьев", "DSK-0037"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_tooth_count_infers_saw_blade_without_product_name(
|
||||||
|
matcher: CatalogMatcher, query: str, sku: str
|
||||||
|
) -> None:
|
||||||
|
result = matcher.match(query)
|
||||||
|
|
||||||
|
assert result.status == "matched"
|
||||||
|
assert [candidate.sku for candidate in result.candidates] == [sku]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("query", "skus"),
|
||||||
|
[
|
||||||
|
("24 зуба", ("DSK-0029", "DSK-0032", "DSK-0035")),
|
||||||
|
("36 зубов", ("DSK-0030", "DSK-0033", "DSK-0036")),
|
||||||
|
("48 зубьев", ("DSK-0031", "DSK-0034", "DSK-0037")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_bare_tooth_count_returns_all_matching_saw_blade_sizes(
|
||||||
|
matcher: CatalogMatcher, query: str, skus: tuple[str, ...]
|
||||||
|
) -> None:
|
||||||
|
result = matcher.match(query)
|
||||||
|
|
||||||
|
assert result.status == "ambiguous"
|
||||||
|
assert tuple(candidate.sku for candidate in result.candidates) == skus
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_tooth_count_does_not_return_nearest_saw_blade(
|
||||||
|
matcher: CatalogMatcher,
|
||||||
|
) -> None:
|
||||||
|
result = matcher.match("210 мм 25 зубьев")
|
||||||
|
|
||||||
|
assert result.status == "not_found"
|
||||||
|
assert result.candidates == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"query",
|
||||||
|
[
|
||||||
|
"пильный диск 190x48t",
|
||||||
|
"190x48T",
|
||||||
|
"190x48z",
|
||||||
|
"190 48t",
|
||||||
|
"190/48T",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_compact_saw_notation_parses_diameter_and_teeth(
|
||||||
|
matcher: CatalogMatcher, query: str
|
||||||
|
) -> None:
|
||||||
|
result = matcher.match(query)
|
||||||
|
|
||||||
|
assert result.status == "matched"
|
||||||
|
assert [candidate.sku for candidate in result.candidates] == ["DSK-0034"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bare_t_suffix_infers_saw_blades(matcher: CatalogMatcher) -> None:
|
||||||
|
result = matcher.match("24T")
|
||||||
|
|
||||||
|
assert result.status == "ambiguous"
|
||||||
|
assert [candidate.sku for candidate in result.candidates] == [
|
||||||
|
"DSK-0029",
|
||||||
|
"DSK-0032",
|
||||||
|
"DSK-0035",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("query", "status", "skus"),
|
||||||
|
[
|
||||||
|
("125 мм", "ambiguous", ("INS-0013", "DSK-0025", "RAS-0054")),
|
||||||
|
("12 В", "ambiguous", ("INS-0003", "INS-0010", "INS-0024")),
|
||||||
|
("900 Вт", "ambiguous", ("INS-0009", "INS-0013")),
|
||||||
|
("M10", "ambiguous", ("KRP-0030", "KRP-0020", "KRP-0009")),
|
||||||
|
("P120", "ambiguous", ("DSK-0024", "RAS-0050", "RAS-0057")),
|
||||||
|
("SDS-plus", "ambiguous", ("BIT-0073", "INS-0012")),
|
||||||
|
("PH2", "ambiguous", ("BIT-0004", "RIN-0010")),
|
||||||
|
("20x20x2", "matched", ("TRB-0003",)),
|
||||||
|
("ToolKraft 125 мм", "matched", ("INS-0027",)),
|
||||||
|
("99999 мм", "not_found", ()),
|
||||||
|
("аквариум 125 мм", "not_found", ()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_attribute_only_queries_use_exact_catalog_values(
|
||||||
|
matcher: CatalogMatcher,
|
||||||
|
query: str,
|
||||||
|
status: str,
|
||||||
|
skus: tuple[str, ...],
|
||||||
|
) -> None:
|
||||||
|
result = matcher.match(query)
|
||||||
|
|
||||||
|
assert result.status == status
|
||||||
|
assert tuple(candidate.sku for candidate in result.candidates) == skus
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.matcher import CatalogMatcher
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
MESSAGE_PATHS = (ROOT / "messages.txt", ROOT / "more_messages.txt")
|
||||||
|
MatchStatus = Literal["matched", "ambiguous", "not_found"]
|
||||||
|
ExpectedResult = tuple[MatchStatus, tuple[str, ...]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class MessageCase:
|
||||||
|
id: str
|
||||||
|
source: str
|
||||||
|
line_number: int
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
def _load_message_cases() -> tuple[MessageCase, ...]:
|
||||||
|
cases: list[MessageCase] = []
|
||||||
|
for path in MESSAGE_PATHS:
|
||||||
|
for line_number, raw_line in enumerate(
|
||||||
|
path.read_text(encoding="utf-8").splitlines(), start=1
|
||||||
|
):
|
||||||
|
message = raw_line.strip()
|
||||||
|
if not message:
|
||||||
|
continue
|
||||||
|
cases.append(
|
||||||
|
MessageCase(
|
||||||
|
id=f"{path.name}:{line_number}",
|
||||||
|
source=path.name,
|
||||||
|
line_number=line_number,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(cases)
|
||||||
|
|
||||||
|
|
||||||
|
MESSAGE_CASES = _load_message_cases()
|
||||||
|
VALIDATED_MESSAGE_CASES = tuple(
|
||||||
|
case for case in MESSAGE_CASES if case.source == "messages.txt"
|
||||||
|
)
|
||||||
|
MORE_MESSAGE_CASES = tuple(
|
||||||
|
case for case in MESSAGE_CASES if case.source == "more_messages.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hand-reviewed acceptance results for every required messages.txt query.
|
||||||
|
# Do not regenerate this table from CatalogMatcher output: it is the independent
|
||||||
|
# oracle that should catch semantic regressions in the implementation.
|
||||||
|
VALIDATED_MESSAGE_RESULTS: dict[str, ExpectedResult] = {
|
||||||
|
"здравствуйте, есть саморезы гкл 3.5х25?": ("ambiguous", ("SAM-0063", "SAM-0061", "SAM-0062")),
|
||||||
|
"дрель ударная prowerk pw-750 в наличии?": ("matched", ("INS-0008",)),
|
||||||
|
"кабель шввп 2х0.5 сколько за метр": ("matched", ("KAB-0017",)),
|
||||||
|
"диск пильный 190 на 48 зубьев": ("matched", ("DSK-0034",)),
|
||||||
|
"уровень 1000 мм есть?": ("matched", ("RIN-0024",)),
|
||||||
|
"гайки м10 нужны": ("matched", ("KRP-0020",)),
|
||||||
|
"лента фум 12 мм": ("matched", ("RAS-0060",)),
|
||||||
|
"перчатки нитриловые есть?": ("matched", ("PER-0002",)),
|
||||||
|
"проф труба 20х20 стенка полтора": ("matched", ("TRB-0002",)),
|
||||||
|
"шурик на 12в недорогой": ("ambiguous", ("INS-0017", "INS-0010", "INS-0003")),
|
||||||
|
"болгарка на 230 какая есть": ("ambiguous", ("INS-0014", "INS-0028", "INS-0007")),
|
||||||
|
"гкл 9.5 сколько лист": ("matched", ("GKL-0001",)),
|
||||||
|
"хомуты пластиковые 4.8х400": ("matched", ("RAS-0014",)),
|
||||||
|
"сдс бур 6 на 110": ("matched", ("BIT-0074",)),
|
||||||
|
"наждачка р120 листами": ("matched", ("RAS-0050",)),
|
||||||
|
"круг зачистной на 125": ("matched", ("DSK-0018",)),
|
||||||
|
"бита ph2 50 мм": ("matched", ("BIT-0005",)),
|
||||||
|
"саморезы по дереву 3.5х45 пачка 200": ("matched", ("SAM-0010",)),
|
||||||
|
"труба профильная 20х20х2": ("matched", ("TRB-0003",)),
|
||||||
|
"ввгнг лс 3х1.5": ("matched", ("KAB-0010",)),
|
||||||
|
"диск отрезной по металлу 115х1.2": ("matched", ("DSK-0002",)),
|
||||||
|
"бур sds 10х210": ("matched", ("BIT-0080",)),
|
||||||
|
"дюбель-гвоздь 6х60": ("matched", ("KRP-0048",)),
|
||||||
|
"саморезы по металлу 4.2х70": ("not_found", ()),
|
||||||
|
"труба профильная 35х35": ("not_found", ()),
|
||||||
|
"кабель ввгнг 4х2.5": ("not_found", ()),
|
||||||
|
"ушм на 150": ("not_found", ()),
|
||||||
|
"бита t50": ("not_found", ()),
|
||||||
|
"шпилька м16 метровая": ("not_found", ()),
|
||||||
|
"нужен кабель": ("ambiguous", ("KAB-0004", "KAB-0005", "KAB-0008")),
|
||||||
|
"дайте дюбелей": ("ambiguous", ("KRP-0050", "KRP-0047", "KRP-0048")),
|
||||||
|
"сверло нужно": ("ambiguous", ("BIT-0060", "BIT-0061", "BIT-0062")),
|
||||||
|
"какие есть диски": ("ambiguous", ("DSK-0011", "DSK-0012", "DSK-0013")),
|
||||||
|
"перфоратор посоветуйте": ("ambiguous", ("INS-0012", "INS-0026", "INS-0005")),
|
||||||
|
"шуруповерт как у макиты, только дешевле": ("ambiguous", ("INS-0017", "INS-0010", "INS-0003")),
|
||||||
|
"здравствуйте, вы до скольки работаете?": ("not_found", ()),
|
||||||
|
"можно оплатить картой при получении?": ("not_found", ()),
|
||||||
|
"где находится ваш магазин": ("not_found", ()),
|
||||||
|
"статус заказа 4512 подскажите": ("not_found", ()),
|
||||||
|
"спасибо, заказ получил, все отлично": ("not_found", ()),
|
||||||
|
}
|
||||||
|
|
||||||
|
# The larger exploratory corpus is useful as a regression snapshot, but it is
|
||||||
|
# intentionally kept separate from the hand-reviewed acceptance set above.
|
||||||
|
MORE_MESSAGE_BASELINE_RESULTS: dict[str, ExpectedResult] = {
|
||||||
|
"добрый день, саморезы по дереву 3.5х25 пачка 200 штук": ("matched", ("SAM-0001",)),
|
||||||
|
"саморез гкл 3,5 на 25 кг": ("matched", ("SAM-0063",)),
|
||||||
|
"нужны черные саморезы 4.2x75 упаковка 1000": ("matched", ("SAM-0023",)),
|
||||||
|
"самарезы па дереву 3.5х45 200шт": ("ambiguous", ("SAM-0010", "SAM-0012", "SAM-0011")),
|
||||||
|
"саморезы 99х999": ("not_found", ()),
|
||||||
|
"дюбель гвоздь 6x40": ("matched", ("KRP-0047",)),
|
||||||
|
"дюбеля 8 на 80 есть?": ("matched", ("KRP-0049",)),
|
||||||
|
"анкерный болт м10": ("ambiguous", ("KRP-0009", "KRP-0010", "KRP-0011")),
|
||||||
|
"гайка м8 оцинкованная": ("matched", ("KRP-0019",)),
|
||||||
|
"шайбы м12 сколько стоят": ("ambiguous", ("KRP-0032", "KRP-0031")),
|
||||||
|
"болт м6х30 50 штук": ("ambiguous", ("KRP-0002", "KRP-0001", "KRP-0003")),
|
||||||
|
"шпилька м10 один метр": ("matched", ("KRP-0034",)),
|
||||||
|
"крепеж какой-нибудь": ("not_found", ()),
|
||||||
|
"кабель ввг нг ls 3*2,5": ("matched", ("KAB-0011",)),
|
||||||
|
"провод пвс 2х1.5 нужен": ("not_found", ()),
|
||||||
|
"ШВВП 2 X 0,75, цена за метр?": ("matched", ("KAB-0018",)),
|
||||||
|
"кабель кг 3х2,5": ("not_found", ()),
|
||||||
|
"кабель для чайника": ("ambiguous", ("KAB-0004", "KAB-0005", "KAB-0008")),
|
||||||
|
"3х1.5": ("ambiguous", ("KAB-0022", "KAB-0002", "KAB-0010")),
|
||||||
|
"ввгнг 20х20": ("not_found", ()),
|
||||||
|
"труба профильная 40x20x2": ("not_found", ()),
|
||||||
|
"профтруба 60 40 3": ("ambiguous", ("TRB-0009", "TRB-0010", "TRB-0015")),
|
||||||
|
"труба квадратная двадцать на двадцать": ("ambiguous", ("TRB-0003", "TRB-0007", "TRB-0008")),
|
||||||
|
"труба 35х35х2": ("not_found", ()),
|
||||||
|
"нужна труба длиной до луны": ("ambiguous", ("TRB-0003", "TRB-0007", "TRB-0008")),
|
||||||
|
"гипсокартон 12,5 влагостойкий": ("matched", ("GKL-0003",)),
|
||||||
|
"гкл обычный 9.5 мм": ("matched", ("GKL-0001",)),
|
||||||
|
"лист гипса зеленый": ("ambiguous", ("GKL-0001", "GKL-0002", "GKL-0004")),
|
||||||
|
"гипсокортан 12.5": ("ambiguous", ("GKL-0002", "GKL-0004", "GKL-0003")),
|
||||||
|
"фанера 18 мм": ("not_found", ()),
|
||||||
|
"дрель prowerk pw-750": ("matched", ("INS-0008",)),
|
||||||
|
"аккумуляторная дрель 18в": ("not_found", ()),
|
||||||
|
"шуруповёрт 12 вольт самый дешёвый": ("ambiguous", ("INS-0017", "INS-0010", "INS-0003")),
|
||||||
|
"перфоратор sds plus 800 вт": ("not_found", ()),
|
||||||
|
"болгарка 125": ("ambiguous", ("INS-0013", "INS-0027", "INS-0006")),
|
||||||
|
"ушм 230 мм 2200 вт": ("not_found", ()),
|
||||||
|
"лобзик посоветуйте": ("ambiguous", ("INS-0031", "INS-0029")),
|
||||||
|
"makita df333 есть?": ("not_found", ()),
|
||||||
|
"инструмент чтобы сверлить стену": ("ambiguous", ("BIT-0067", "BIT-0068", "BIT-0069")),
|
||||||
|
"дрэл 9999 ват": ("not_found", ()),
|
||||||
|
"бур sds-plus 8x160": ("matched", ("BIT-0077",)),
|
||||||
|
"бур 10 на 210 sds+": ("matched", ("BIT-0080",)),
|
||||||
|
"бур SDS MAX 20х400": ("not_found", ()),
|
||||||
|
"сверло по металлу 6 мм": ("matched", ("BIT-0045",)),
|
||||||
|
"сверло по дереву 10x120": ("not_found", ()),
|
||||||
|
"бита ph2 25": ("matched", ("BIT-0004",)),
|
||||||
|
"набор бит torx": ("not_found", ()),
|
||||||
|
"бита t30 50 мм": ("matched", ("BIT-0029",)),
|
||||||
|
"бита t99": ("not_found", ()),
|
||||||
|
"насадка на шуруповерт": ("ambiguous", ("INS-0010", "INS-0011", "INS-0024")),
|
||||||
|
"диск отрезной 125х1,0 металл": ("matched", ("DSK-0003",)),
|
||||||
|
"круг по металлу 230 на 2.5": ("not_found", ()),
|
||||||
|
"пильный диск 190x48t": ("matched", ("DSK-0034",)),
|
||||||
|
"алмазный диск 125 бетон": ("ambiguous", ("DSK-0025", "DSK-0026", "DSK-0027")),
|
||||||
|
"диск на болгарку": ("ambiguous", ("DSK-0011", "DSK-0012", "DSK-0013")),
|
||||||
|
"круг квадратный 12х34": ("not_found", ()),
|
||||||
|
"наждачная бумага p80": ("matched", ("RAS-0049",)),
|
||||||
|
"шкурка нулёвка": ("ambiguous", ("RAS-0049", "RAS-0048", "RAS-0052")),
|
||||||
|
"круг лепестковый 125 р40": ("matched", ("RAS-0054",)),
|
||||||
|
"лента фум 19 мм": ("not_found", ()),
|
||||||
|
"изолента синяя 15 мм": ("not_found", ()),
|
||||||
|
"хомут 4,8x300 черный": ("not_found", ()),
|
||||||
|
"стяжки пластиковые": ("ambiguous", ("RAS-0009", "RAS-0011", "RAS-0012")),
|
||||||
|
"перчатки нитриловые размер l": ("matched", ("PER-0002",)),
|
||||||
|
"перчатки хб с пвх": ("matched", ("PER-0001",)),
|
||||||
|
"мешки для мусора 120 литров": ("ambiguous", ("RAS-0035", "RAS-0034")),
|
||||||
|
"пена монтажная зимняя": ("ambiguous", ("RAS-0015", "RAS-0016")),
|
||||||
|
"герметик силиконовый прозрачный": ("ambiguous", ("RAS-0018", "RAS-0017")),
|
||||||
|
"клей жидкие гвозди": ("ambiguous", ("KRP-0043", "KRP-0044", "KRP-0045")),
|
||||||
|
"WD-40 400 мл": ("not_found", ()),
|
||||||
|
"здравствуйте": ("not_found", ()),
|
||||||
|
"вы сегодня открыты?": ("not_found", ()),
|
||||||
|
"какой адрес": ("not_found", ()),
|
||||||
|
"есть доставка в химки?": ("not_found", ()),
|
||||||
|
"можно вернуть товар без чека": ("not_found", ()),
|
||||||
|
"примите заказ 12345": ("not_found", ()),
|
||||||
|
"где мой заказ 000001": ("not_found", ()),
|
||||||
|
"оплатил, но денег нет": ("not_found", ()),
|
||||||
|
"спасибо большое!": ("not_found", ()),
|
||||||
|
"позовите человека": ("not_found", ()),
|
||||||
|
"1234567890": ("not_found", ()),
|
||||||
|
"000000000000000000": ("not_found", ()),
|
||||||
|
"42": ("not_found", ()),
|
||||||
|
"qwerty asdf zxcv": ("not_found", ()),
|
||||||
|
"йцукен фывапр олдж": ("not_found", ()),
|
||||||
|
"абракадабра крокодил вертолёт": ("not_found", ()),
|
||||||
|
"!@#$%^&*()": ("not_found", ()),
|
||||||
|
"..........": ("not_found", ()),
|
||||||
|
"купить купить купить": ("not_found", ()),
|
||||||
|
"null": ("not_found", ()),
|
||||||
|
"None": ("not_found", ()),
|
||||||
|
"SELECT * FROM catalog;": ("not_found", ()),
|
||||||
|
"<script>alert(1)</script>": ("not_found", ()),
|
||||||
|
"../../etc/passwd": ("not_found", ()),
|
||||||
|
"🤖🔩🔨": ("not_found", ()),
|
||||||
|
"САМОБРЕЗЫ ГКЛ 3.5Х25???": ("not_found", ()),
|
||||||
|
"ну эээ диск этот круглый короче": ("ambiguous", ("DSK-0011", "DSK-0012", "DSK-0013")),
|
||||||
|
"мне два метра того красного": ("not_found", ()),
|
||||||
|
"хочу всё и сразу за сто рублей": ("not_found", ()),
|
||||||
|
"статус заказа 4512 и бур 8х160": ("matched", ("BIT-0077",)),
|
||||||
|
"диск": ("ambiguous", ("DSK-0011", "DSK-0012", "DSK-0013")),
|
||||||
|
"24 зуба": ("ambiguous", ("DSK-0029", "DSK-0032", "DSK-0035")),
|
||||||
|
"210 мм 24 зуба": ("matched", ("DSK-0035",)),
|
||||||
|
"125 мм": ("ambiguous", ("INS-0013", "DSK-0025", "RAS-0054")),
|
||||||
|
"12 в": ("ambiguous", ("INS-0003", "INS-0010", "INS-0024")),
|
||||||
|
"900 вт": ("ambiguous", ("INS-0009", "INS-0013")),
|
||||||
|
"M10": ("ambiguous", ("KRP-0030", "KRP-0020", "KRP-0009")),
|
||||||
|
"P120": ("ambiguous", ("DSK-0024", "RAS-0050", "RAS-0057")),
|
||||||
|
"SDS-plus": ("ambiguous", ("BIT-0073", "INS-0012")),
|
||||||
|
"PH2": ("ambiguous", ("BIT-0004", "RIN-0010")),
|
||||||
|
"20x20x2": ("matched", ("TRB-0003",)),
|
||||||
|
"ToolKraft 125 мм": ("matched", ("INS-0027",)),
|
||||||
|
"99999 мм": ("not_found", ()),
|
||||||
|
"аквариум 125 мм": ("not_found", ()),
|
||||||
|
"удар": ("ambiguous", ("INS-0008", "INS-0009", "INS-0022")),
|
||||||
|
"ударная": ("ambiguous", ("INS-0008", "INS-0009", "INS-0022")),
|
||||||
|
"удар мощный": ("ambiguous", ("INS-0008", "INS-0009", "INS-0022")),
|
||||||
|
"удар мощно 900 хромированый анус долбить": ("ambiguous", ("INS-0009", "INS-0016", "INS-0023")),
|
||||||
|
"дрель удар мощно 900 хромированый анус долбить": ("ambiguous", ("INS-0009", "INS-0023", "INS-0002")),
|
||||||
|
"анус": ("not_found", ()),
|
||||||
|
"долбить": ("not_found", ()),
|
||||||
|
"Арс-12": ("matched", ("INS-0017",)),
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPECTED_RESULTS = VALIDATED_MESSAGE_RESULTS | MORE_MESSAGE_BASELINE_RESULTS
|
||||||
|
|
||||||
|
|
||||||
|
def test_message_files_are_present_and_have_expected_size() -> None:
|
||||||
|
counts = {
|
||||||
|
path.name: sum(
|
||||||
|
bool(line.strip())
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines()
|
||||||
|
)
|
||||||
|
for path in MESSAGE_PATHS
|
||||||
|
}
|
||||||
|
assert counts["messages.txt"] > 0
|
||||||
|
assert counts["more_messages.txt"] >= 100
|
||||||
|
|
||||||
|
messages = [case.message for case in MESSAGE_CASES]
|
||||||
|
assert len(messages) == len(set(messages)), "message texts must be unique"
|
||||||
|
assert set(EXPECTED_RESULTS) == set(messages)
|
||||||
|
assert set(VALIDATED_MESSAGE_RESULTS) == {
|
||||||
|
case.message for case in MESSAGE_CASES if case.source == "messages.txt"
|
||||||
|
}
|
||||||
|
assert set(MORE_MESSAGE_BASELINE_RESULTS) == {
|
||||||
|
case.message for case in MESSAGE_CASES if case.source == "more_messages.txt"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_expected_result(
|
||||||
|
matcher: CatalogMatcher, case: MessageCase
|
||||||
|
) -> None:
|
||||||
|
result = matcher.match(case.message)
|
||||||
|
|
||||||
|
assert result.message == case.message
|
||||||
|
assert result.status in {"matched", "ambiguous", "not_found"}
|
||||||
|
assert len(result.candidates) <= 3
|
||||||
|
assert len({candidate.sku for candidate in result.candidates}) == len(
|
||||||
|
result.candidates
|
||||||
|
)
|
||||||
|
assert all(0.0 <= candidate.confidence <= 1.0 for candidate in result.candidates)
|
||||||
|
|
||||||
|
if result.status == "matched":
|
||||||
|
assert len(result.candidates) == 1
|
||||||
|
elif result.status == "ambiguous":
|
||||||
|
assert 2 <= len(result.candidates) <= 3
|
||||||
|
else:
|
||||||
|
assert result.candidates == []
|
||||||
|
|
||||||
|
expected_status, expected_skus = EXPECTED_RESULTS[case.message]
|
||||||
|
assert result.status == expected_status
|
||||||
|
assert tuple(candidate.sku for candidate in result.candidates) == expected_skus
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"case", VALIDATED_MESSAGE_CASES, ids=lambda case: case.id
|
||||||
|
)
|
||||||
|
def test_validated_messages_txt_result(
|
||||||
|
matcher: CatalogMatcher, case: MessageCase
|
||||||
|
) -> None:
|
||||||
|
_assert_expected_result(matcher, case)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("case", MORE_MESSAGE_CASES, ids=lambda case: case.id)
|
||||||
|
def test_more_messages_regression(
|
||||||
|
matcher: CatalogMatcher, case: MessageCase
|
||||||
|
) -> None:
|
||||||
|
_assert_expected_result(matcher, case)
|
||||||
Reference in new issue
Block a user