Add regression corpus and opt-in catalog test route

This commit is contained in:
Fiden
2026-08-07 17:44:17 +03:00
parent 11e1a4c4a2
commit e23fdcfc0d
6 changed files with 475 additions and 12 deletions
+39
View File
@@ -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,
)
+42 -11
View File
@@ -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())
+101
View File
@@ -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;
<script>alert(1)</script>
../../etc/passwd
🤖🔩🔨
САМОБРЕЗЫ ГКЛ 3.5Х25???
ну эээ диск этот круглый короче
мне два метра того красного
хочу всё и сразу за сто рублей
статус заказа 4512 и бур 8х160
диск
+45 -1
View File
@@ -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"]
+14
View File
@@ -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)
+234
View File
@@ -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