From 4286a92fcc429c1bd69f20becd60064db89374ba Mon Sep 17 00:00:00 2001 From: Fiden Date: Fri, 7 Aug 2026 18:47:52 +0300 Subject: [PATCH] Support incomplete model suffixes --- app/attributes.py | 37 ++++++++++++++++++++++++++- app/matcher.py | 26 ++++++++++++++++--- more_messages.txt | 1 + tests/test_matcher.py | 48 ++++++++++++++++++++++++++++++++++++ tests/test_message_corpus.py | 1 + 5 files changed, 108 insertions(+), 5 deletions(-) diff --git a/app/attributes.py b/app/attributes.py index 1dbe5d7..8f08430 100644 --- a/app/attributes.py +++ b/app/attributes.py @@ -44,6 +44,7 @@ _MODEL_RE = re.compile( r"(? 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: @@ -645,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 diff --git a/app/matcher.py b/app/matcher.py index 5fa53ec..03b4b5a 100644 --- a/app/matcher.py +++ b/app/matcher.py @@ -10,6 +10,7 @@ from app.attributes import ( Compatibility, ParsedAttributes, compare_attributes, + model_code_matches, parse_attributes, ) from app.catalog import CatalogItem, build_catalog, load_raw_catalog @@ -109,11 +110,15 @@ 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: @@ -393,7 +398,20 @@ class CatalogMatcher: 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]) diff --git a/more_messages.txt b/more_messages.txt index e835dff..67d0bac 100644 --- a/more_messages.txt +++ b/more_messages.txt @@ -119,3 +119,4 @@ ToolKraft 125 мм дрель удар мощно 900 хромированый анус долбить анус долбить +Арс-12 diff --git a/tests/test_matcher.py b/tests/test_matcher.py index cfc86eb..9971c49 100644 --- a/tests/test_matcher.py +++ b/tests/test_matcher.py @@ -177,6 +177,54 @@ def test_unknown_toolkraft_model_does_not_fall_back_to_brand_results( 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) diff --git a/tests/test_message_corpus.py b/tests/test_message_corpus.py index c350927..579f164 100644 --- a/tests/test_message_corpus.py +++ b/tests/test_message_corpus.py @@ -221,6 +221,7 @@ MORE_MESSAGE_BASELINE_RESULTS: dict[str, ExpectedResult] = { "дрель удар мощно 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