Cover matcher and API scenarios
This commit is contained in:
+1
-1
@@ -84,7 +84,7 @@ PRODUCT_ALIASES = [
|
||||
},
|
||||
{
|
||||
"canonical": "круг",
|
||||
"aliases": ["круг шлифовальный", "шлифовальный круг", "круг на липучке"],
|
||||
"aliases": ["круг", "круг шлифовальный", "шлифовальный круг", "круг на липучке"],
|
||||
},
|
||||
{"canonical": "дрель", "aliases": ["дрель", "дрели", "ударная дрель"]},
|
||||
{"canonical": "лобзик", "aliases": ["лобзик", "электролобзик"]},
|
||||
|
||||
+5
-3
@@ -426,6 +426,9 @@ def parse_attributes(
|
||||
if not catalog_item and "метровая" in normalized and not meter_values:
|
||||
meter_values.append(Decimal("1"))
|
||||
|
||||
pack_match = (_CATALOG_PACK_RE if catalog_item else _PACK_SIZE_RE).search(normalized)
|
||||
pack_size = int(pack_match.group(1)) if pack_match else None
|
||||
|
||||
bare_size_types = {
|
||||
"ушм",
|
||||
"диск",
|
||||
@@ -453,6 +456,8 @@ def parse_attributes(
|
||||
]
|
||||
if teeth is not None:
|
||||
bare_numbers = [value for value in bare_numbers if value != Decimal(teeth)]
|
||||
if pack_size is not None:
|
||||
bare_numbers = [value for value in bare_numbers if value != Decimal(pack_size)]
|
||||
if bare_numbers:
|
||||
mm_values.append(bare_numbers[0])
|
||||
|
||||
@@ -481,9 +486,6 @@ def parse_attributes(
|
||||
power_match = _POWER_RE.search(normalized)
|
||||
power_w = _decimal(power_match.group(1)) if power_match else None
|
||||
|
||||
pack_match = (_CATALOG_PACK_RE if catalog_item else _PACK_SIZE_RE).search(normalized)
|
||||
pack_size = int(pack_match.group(1)) if pack_match else None
|
||||
|
||||
unit_hint: str | None = None
|
||||
if not catalog_item:
|
||||
if re.search(r"\b(?:пачк\w*|упаковк\w*|уп\.)\b", normalized):
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
testpaths = tests
|
||||
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.matcher import CatalogMatcher
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def matcher() -> CatalogMatcher:
|
||||
return CatalogMatcher(Path(__file__).resolve().parents[1] / "catalog_excel.csv")
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_service_exposes_only_match_endpoint() -> None:
|
||||
application_paths = [route.path for route in app.routes]
|
||||
assert application_paths == ["/match"]
|
||||
|
||||
|
||||
def test_match_contract_and_message_order() -> None:
|
||||
messages = [
|
||||
"бур sds 10х210",
|
||||
"где находится ваш магазин",
|
||||
"нужен кабель",
|
||||
]
|
||||
response = client.post("/match", json={"messages": messages})
|
||||
assert response.status_code == 200
|
||||
|
||||
body = response.json()
|
||||
assert set(body) == {"results"}
|
||||
assert [result["message"] for result in body["results"]] == messages
|
||||
assert [result["status"] for result in body["results"]] == [
|
||||
"matched",
|
||||
"not_found",
|
||||
"ambiguous",
|
||||
]
|
||||
|
||||
for result in body["results"]:
|
||||
assert set(result) == {"message", "status", "candidates"}
|
||||
assert len(result["candidates"]) <= 3
|
||||
for candidate in result["candidates"]:
|
||||
assert set(candidate) == {"sku", "confidence"}
|
||||
assert 0.0 <= candidate["confidence"] <= 1.0
|
||||
|
||||
|
||||
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_unknown_request_fields_are_rejected() -> None:
|
||||
response = client.post("/match", json={"messages": [], "debug": True})
|
||||
assert response.status_code == 422
|
||||
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.matcher import CatalogMatcher
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("message", "sku"),
|
||||
[
|
||||
("дрель ударная prowerk pw-750 в наличии?", "INS-0008"),
|
||||
("кабель шввп 2х0.5 сколько за метр", "KAB-0017"),
|
||||
("диск пильный 190 на 48 зубьев", "DSK-0034"),
|
||||
("уровень 1000 мм есть?", "RIN-0024"),
|
||||
("гайки м10 нужны", "KRP-0020"),
|
||||
("лента фум 12 мм", "RAS-0060"),
|
||||
("перчатки нитриловые есть?", "PER-0002"),
|
||||
("проф труба 20х20 стенка полтора", "TRB-0002"),
|
||||
("гкл 9.5 сколько лист", "GKL-0001"),
|
||||
("хомуты пластиковые 4.8х400", "RAS-0014"),
|
||||
("сдс бур 6 на 110", "BIT-0074"),
|
||||
("наждачка р120 листами", "RAS-0050"),
|
||||
("круг зачистной на 125", "DSK-0018"),
|
||||
("бита ph2 50 мм", "BIT-0005"),
|
||||
("саморезы по дереву 3.5х45 пачка 200", "SAM-0010"),
|
||||
("труба профильная 20х20х2", "TRB-0003"),
|
||||
("ввгнг лс 3х1.5", "KAB-0010"),
|
||||
("диск отрезной по металлу 115х1.2", "DSK-0002"),
|
||||
("бур sds 10х210", "BIT-0080"),
|
||||
("дюбель-гвоздь 6х60", "KRP-0048"),
|
||||
],
|
||||
)
|
||||
def test_unique_queries_are_matched(
|
||||
matcher: CatalogMatcher, message: str, sku: str
|
||||
) -> None:
|
||||
result = matcher.match(message)
|
||||
assert result.status == "matched"
|
||||
assert [candidate.sku for candidate in result.candidates] == [sku]
|
||||
assert 0.0 <= result.candidates[0].confidence <= 1.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
[
|
||||
"саморезы по металлу 4.2х70",
|
||||
"труба профильная 35х35",
|
||||
"кабель ввгнг 4х2.5",
|
||||
"ушм на 150",
|
||||
"бита t50",
|
||||
"шпилька м16 метровая",
|
||||
"бур 10х220",
|
||||
"бита ph2 70 мм",
|
||||
],
|
||||
)
|
||||
def test_explicit_attribute_conflicts_are_not_found(
|
||||
matcher: CatalogMatcher, message: str
|
||||
) -> None:
|
||||
result = matcher.match(message)
|
||||
assert result.status == "not_found"
|
||||
assert result.candidates == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
[
|
||||
"нужен кабель",
|
||||
"дайте дюбелей",
|
||||
"сверло нужно",
|
||||
"какие есть диски",
|
||||
"перфоратор посоветуйте",
|
||||
"шуруповерт как у макиты, только дешевле",
|
||||
"шурик на 12в недорогой",
|
||||
"болгарка на 230 какая есть",
|
||||
"гкл 12.5",
|
||||
],
|
||||
)
|
||||
def test_under_specified_product_queries_are_ambiguous(
|
||||
matcher: CatalogMatcher, message: str
|
||||
) -> None:
|
||||
result = matcher.match(message)
|
||||
assert result.status == "ambiguous"
|
||||
assert 2 <= len(result.candidates) <= 3
|
||||
|
||||
|
||||
def test_unspecified_packaging_remains_ambiguous(matcher: CatalogMatcher) -> None:
|
||||
result = matcher.match("здравствуйте, есть саморезы гкл 3.5х25?")
|
||||
assert result.status == "ambiguous"
|
||||
assert {candidate.sku for candidate in result.candidates} == {
|
||||
"SAM-0061",
|
||||
"SAM-0062",
|
||||
"SAM-0063",
|
||||
}
|
||||
|
||||
|
||||
def test_package_word_filters_out_kilogram_item(matcher: CatalogMatcher) -> None:
|
||||
result = matcher.match("саморезы по дереву 4.2 на 75, пачку")
|
||||
assert result.status == "ambiguous"
|
||||
assert {candidate.sku for candidate in result.candidates} == {
|
||||
"SAM-0022",
|
||||
"SAM-0023",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
[
|
||||
"здравствуйте, вы до скольки работаете?",
|
||||
"можно оплатить картой при получении?",
|
||||
"где находится ваш магазин",
|
||||
"статус заказа 4512 подскажите",
|
||||
"спасибо, заказ получил, все отлично",
|
||||
"",
|
||||
],
|
||||
)
|
||||
def test_non_product_messages_are_not_found(
|
||||
matcher: CatalogMatcher, message: str
|
||||
) -> None:
|
||||
result = matcher.match(message)
|
||||
assert result.status == "not_found"
|
||||
assert result.candidates == []
|
||||
|
||||
|
||||
def test_product_evidence_has_priority_over_service_words(matcher: CatalogMatcher) -> None:
|
||||
result = matcher.match("статус заказа 4512 и еще нужен бур 8х160")
|
||||
assert result.status == "matched"
|
||||
assert result.candidates[0].sku == "BIT-0077"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query", ["шурпуоверт на 12в", "шураыввавёрт на 12в"])
|
||||
def test_product_type_survives_typos(matcher: CatalogMatcher, query: str) -> None:
|
||||
result = matcher.match(query)
|
||||
assert result.status == "ambiguous"
|
||||
assert all(candidate.sku.startswith("INS-") for candidate in 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"
|
||||
|
||||
|
||||
def test_unknown_size_does_not_fall_back_to_nearest_product(
|
||||
matcher: CatalogMatcher,
|
||||
) -> None:
|
||||
result = matcher.match("ушм 150")
|
||||
assert result.status == "not_found"
|
||||
assert result.candidates == []
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.aliases import ProductAliasIndex
|
||||
from app.normalization import normalize_text
|
||||
from app.typo import damerau_levenshtein_distance, word_similarity
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "expected"),
|
||||
[
|
||||
("Шуруповёрт", "шуруповерт"),
|
||||
("3,5 × 25", "3.5x25"),
|
||||
("4.2 х 75", "4.2x75"),
|
||||
("М10", "m10"),
|
||||
("Р120", "p120"),
|
||||
],
|
||||
)
|
||||
def test_basic_canonicalization(source: str, expected: str) -> None:
|
||||
assert normalize_text(source) == expected
|
||||
|
||||
|
||||
def test_cyrillic_x_is_not_replaced_inside_words() -> None:
|
||||
assert normalize_text("хомут находится") == "хомут находится"
|
||||
|
||||
|
||||
def test_damerau_counts_adjacent_transposition_as_one_edit() -> None:
|
||||
assert damerau_levenshtein_distance("шуруповерт", "шурпуоверт") == 1
|
||||
|
||||
|
||||
def test_heavily_damaged_product_word_still_has_useful_score() -> None:
|
||||
assert word_similarity("шураывваверт", "шуруповерт") > 0.53
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("query", "canonical"),
|
||||
[
|
||||
("болгарка на 230", "ушм"),
|
||||
("шурик на 12в", "шуруповерт"),
|
||||
("пластиковые хомуты 4.8х400", "стяжка"),
|
||||
("шурпуоверт", "шуруповерт"),
|
||||
("шураыввавёрт", "шуруповерт"),
|
||||
],
|
||||
)
|
||||
def test_alias_and_typo_detection(query: str, canonical: str) -> None:
|
||||
match = ProductAliasIndex().detect(query)
|
||||
assert match is not None
|
||||
assert match.canonical == canonical
|
||||
Reference in New Issue
Block a user