Compare commits

...
5 Commits
12 changed files with 1484 additions and 33 deletions

No files matched your search

+33
View File
@@ -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
View File
@@ -30,7 +30,11 @@ _VOLTAGE_RE = re.compile(
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)
_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(
r"(?:пачк\w*|упаковк\w*|\bуп\.?)(?:\s+(?:по|на))?\s*(\d+)\s*(?:шт\w*)?",
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])",
re.IGNORECASE,
)
_NORMALIZED_MODEL_RE = re.compile(r"^([a-zа-я]{2,6})(\d+)([a-zа-я]?)$", re.IGNORECASE)
_PAIR_DIMENSION_TYPES = frozenset(
{
@@ -225,6 +230,31 @@ def _extract_model_codes(normalized: str) -> frozenset[str]:
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:
flattened = normalized.replace("-", " ")
if "sds max" in flattened or "сдс макс" in flattened:
@@ -377,8 +407,15 @@ def parse_attributes(
normalized = normalize_text(text)
dimensions = _extract_dimensions(normalized)
compact_saw_match = _SAW_COMPACT_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:
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)]
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.
saw_pair = re.search(
@@ -632,7 +671,16 @@ def compare_attributes(
return conflict
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:
return conflict
+39
View File
@@ -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,
)
+42 -11
View File
@@ -4,6 +4,7 @@ import os
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.matcher import CatalogMatcher
from app.models import MatchRequest, MatchResponse
@@ -11,18 +12,48 @@ from app.models import MatchRequest, MatchResponse
BASE_DIR = Path(__file__).resolve().parent.parent
CATALOG_PATH = Path(os.environ.get("CATALOG_PATH", BASE_DIR / "catalog_excel.csv"))
MATCHER = CatalogMatcher(CATALOG_PATH)
# The task asks for one application endpoint. OpenAPI/Swagger routes are disabled
# so the running service exposes only POST /match.
app = FastAPI(
title="Offline catalog matcher",
version="0.2.0",
docs_url=None,
redoc_url=None,
openapi_url=None,
)
TEST_INFO_ROUTE_ENV = "ENABLE_TEST_INFO_ROUTE"
@app.post("/match", response_model=MatchResponse)
def match(request: MatchRequest) -> MatchResponse:
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",
version="0.2.0",
docs_url=None,
redoc_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
def _test_info_route_enabled() -> bool:
return os.environ.get(TEST_INFO_ROUTE_ENV, "").strip().lower() in {
"1",
"true",
"yes",
"on",
}
app = create_app(enable_test_info_route=_test_info_route_enabled())
+301 -16
View File
@@ -5,10 +5,18 @@ from dataclasses import dataclass
from pathlib import Path
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.intents import detect_service_intent
from app.models import Candidate, MatchResult
from app.normalization import ZERO_WEIGHT_TOKENS, normalize_text, tokenize
from app.search_index import LexicalIndex, LexicalScore
@@ -20,6 +28,43 @@ class _RankedCandidate:
ranking_score: float
_STRUCTURED_QUERY_WORDS = frozenset(
{
"x",
"max",
"plus",
"sds",
"t",
"z",
"v",
"w",
"в",
"ватт",
"ватта",
"ваттов",
"вольт",
"вольта",
"вольтов",
"зуб",
"зуба",
"зубов",
"зубьев",
"м",
"метр",
"метра",
"метров",
"миллиметр",
"миллиметра",
"миллиметров",
"мм",
"размер",
"размера",
"размером",
"вт",
}
)
class CatalogMatcher:
def __init__(self, catalog_path: Path) -> None:
self.alias_index = ProductAliasIndex()
@@ -31,8 +76,11 @@ class CatalogMatcher:
type_index: dict[str, list[int]] = defaultdict(list)
model_index: dict[str, set[int]] = defaultdict(set)
brand_item_index: dict[str, list[int]] = defaultdict(list)
for index, item in enumerate(self.items):
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:
model_index[model_code].add(index)
self.type_index = {
@@ -41,6 +89,9 @@ class CatalogMatcher:
self.model_index = {
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:
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}
if len(product_types) == 1:
product_type = next(iter(product_types))
exact_model = all(
code in self.model_index
for code in provisional_attributes.model_codes
)
product_match = ProductTypeMatch(
canonical=product_type,
score=1.0,
exact=True,
alias="model_code",
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,
exact=True,
alias="tooth_count",
token_start=0,
)
if product_match is None:
# Product evidence always has priority. Service intent detection
# is only a diagnostic fallback; both unknown and service
# messages share the required not_found response.
detect_service_intent(message)
return MatchResult(message=message, status="not_found", candidates=[])
brand_result = self._match_brand_only(message, provisional_attributes)
if brand_result is not None:
return brand_result
structured_result = self._match_structured_only(
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
query_attributes = parse_attributes(
@@ -132,10 +221,197 @@ class CatalogMatcher:
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]:
if not model_codes:
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):
return frozenset()
result = set(sets[0])
@@ -167,7 +443,7 @@ class CatalogMatcher:
if price_range == 0
else float((maximum_price - item.price) / price_range)
)
ranking_score = lexical.score * 0.68 + cheap_score * 0.32
ranking_score = cheap_score
else:
ranking_score = lexical.score
ranked.append(
@@ -179,12 +455,21 @@ class CatalogMatcher:
)
)
ranked.sort(
key=lambda candidate: (
-candidate.ranking_score,
candidate.item.price if cheap else candidate.item.sku,
if cheap:
ranked.sort(
key=lambda candidate: (
candidate.item.price,
-candidate.lexical.score,
candidate.item.sku,
)
)
else:
ranked.sort(
key=lambda candidate: (
-candidate.ranking_score,
candidate.item.sku,
)
)
)
return ranked
@staticmethod
+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):
+95 -1
View File
@@ -6,7 +6,12 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING
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:
from app.catalog import CatalogItem
@@ -22,6 +27,14 @@ class LexicalScore:
exact_coverage: float
@dataclass(frozen=True, slots=True)
class ProductTypeInference:
product_type: str
score: float
query_word: str
catalog_word: str
class LexicalIndex:
"""Small in-memory lexical index: BM25 + char trigrams + typo alignment."""
@@ -69,6 +82,87 @@ class LexicalIndex:
counts.update(self._trigram_postings.get(trigram, ()))
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(
self,
query_text: str,
+122
View File
@@ -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
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from fastapi.testclient import TestClient
from app.main import app
from app.main import app, create_app
client = TestClient(app)
@@ -38,12 +38,81 @@ 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
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"]
+185
View File
@@ -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,
)
+255
View File
@@ -126,6 +126,105 @@ def test_product_evidence_has_priority_over_service_words(matcher: CatalogMatche
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в"])
def test_product_type_survives_typos(matcher: CatalogMatcher, query: str) -> None:
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)
@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:
assert matcher.match("бита ph2 50").candidates[0].sku == "BIT-0005"
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")
assert result.status == "not_found"
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
+290
View File
@@ -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)