diff --git a/app/dev_routes.py b/app/dev_routes.py new file mode 100644 index 0000000..428e80a --- /dev/null +++ b/app/dev_routes.py @@ -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, + ) diff --git a/app/main.py b/app/main.py index 60e0caf..9075cb0 100644 --- a/app/main.py +++ b/app/main.py @@ -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()) diff --git a/more_messages.txt b/more_messages.txt new file mode 100644 index 0000000..8e0045b --- /dev/null +++ b/more_messages.txt @@ -0,0 +1,101 @@ +добрый день, саморезы по дереву 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; + +../../etc/passwd +🤖🔩🔨 +САМОБРЕЗЫ ГКЛ 3.5Х25??? +ну эээ диск этот круглый короче +мне два метра того красного +хочу всё и сразу за сто рублей +статус заказа 4512 и бур 8х160 +диск \ No newline at end of file diff --git a/tests/test_api.py b/tests/test_api.py index 2866c96..a39a208 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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"] diff --git a/tests/test_matcher.py b/tests/test_matcher.py index a94218f..b85c819 100644 --- a/tests/test_matcher.py +++ b/tests/test_matcher.py @@ -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) diff --git a/tests/test_message_corpus.py b/tests/test_message_corpus.py new file mode 100644 index 0000000..b664a4d --- /dev/null +++ b/tests/test_message_corpus.py @@ -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", ()), + "": ("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