Add regression corpus and opt-in catalog test route
This commit is contained in:
1 parent
11e1a4c4a2
commit
e23fdcfc0d
6 files changed
+475
-12
No files matched your search
+45
-1
@@ -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)
|
||||
|
||||
@@ -47,3 +47,47 @@ def test_empty_message_list_is_valid() -> None:
|
||||
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"]
|
||||
@@ -126,6 +126,20 @@ 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-0010",
|
||||
"INS-0017",
|
||||
"INS-0003",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query", ["шурпуоверт на 12в", "шураыввавёрт на 12в"])
|
||||
def test_product_type_survives_typos(matcher: CatalogMatcher, query: str) -> None:
|
||||
result = matcher.match(query)
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
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()
|
||||
|
||||
# Baseline approved for the current corpus. The key is the message text so adding
|
||||
# or moving unrelated lines does not invalidate every expectation below.
|
||||
EXPECTED_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-0010", "INS-0017", "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-0010", "INS-0017", "INS-0003")),
|
||||
"здравствуйте, вы до скольки работаете?": ("not_found", ()),
|
||||
"можно оплатить картой при получении?": ("not_found", ()),
|
||||
"где находится ваш магазин": ("not_found", ()),
|
||||
"статус заказа 4512 подскажите": ("not_found", ()),
|
||||
"спасибо, заказ получил, все отлично": ("not_found", ()),
|
||||
"добрый день, саморезы по дереву 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": ("not_found", ()),
|
||||
"ввгнг 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",)),
|
||||
"лист гипса зеленый": ("not_found", ()),
|
||||
"гипсокортан 12.5": ("ambiguous", ("GKL-0002", "GKL-0004", "GKL-0003")),
|
||||
"фанера 18 мм": ("not_found", ()),
|
||||
"дрель prowerk pw-750": ("matched", ("INS-0008",)),
|
||||
"аккумуляторная дрель 18в": ("not_found", ()),
|
||||
"шуруповёрт 12 вольт самый дешёвый": ("ambiguous", ("INS-0010", "INS-0017", "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": ("ambiguous", ("DSK-0034", "DSK-0032", "DSK-0033")),
|
||||
"алмазный диск 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")),
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", MESSAGE_CASES, ids=lambda case: case.id)
|
||||
def test_every_message_can_be_matched(
|
||||
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
|
||||
Reference in new issue
Block a user