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