fix(tradein): harden same-building anchor address normalizer for prod formats (#677) (#679)
All checks were successful
Deploy Trade-In / changes (push) Successful in 4s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 22s
Deploy Trade-In / build-backend (push) Successful in 40s
Deploy Trade-In / deploy (push) Successful in 33s

Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
This commit is contained in:
bot-backend 2026-05-29 22:21:29 +00:00 committed by bot-reviewer
parent c30b3c714e
commit b5c26d2723
2 changed files with 216 additions and 29 deletions

View file

@ -855,54 +855,135 @@ def _fetch_dkp_corridor(
# Street-alias map: ЕКБ-специфичные расхождения между golden/source-адресами и БД.
# Golden «Ткачёва 13» = БД «Ткачей 13» — без алиаса 0 комплов для 5 business-кейсов
# (Clever Park). Ключи/значения уже ё→е-нормализованы и lowercase. Расширяемо.
# ВАЖНО: применяется к ЦЕЛЬНОМУ street_core (после strip type-words), не к токену.
_STREET_ALIAS_MAP: dict[str, str] = {
"ткачева": "ткачей", # «ул. Ткачёва» (родит. падеж) ↔ БД «ул. Ткачей»
}
# Токен номера дома: ведущее целое + опциональная литера (рус/лат) + опциональный
# корпус «/N». Примеры: «204Г» → (204, 'г'); «4/2» → (4, None); «48» → (48, None).
_HOUSE_TOKEN_RE = re.compile(
r"(?P<num>\d+)\s*(?P<letter>[а-яёa-z])?(?:\s*[/-]\s*\d+)?",
flags=re.IGNORECASE | re.UNICODE,
# Street-type токены: канонизируем (drop type-слово, оставляем имя). Реальные prod-
# адреса ставят тип ДО или ПОСЛЕ имени («улица Ткачей» И «Олимпийская наб.») — поэтому
# дропаем тип ОТКУДА УГОДНО в строке, а не только ведущий keyword. Лемматизированы по
# точкам/окончаниям: матчим точное слово ИЛИ «<core>.»-сокращение.
_STREET_TYPE_TOKENS: frozenset[str] = frozenset(
{
"улица",
"ул",
"у",
"переулок",
"пер",
"проспект",
"пр",
"прт",
"пркт",
"пр-кт",
"пр-т",
"проезд",
"бульвар",
"бр",
"б-р",
"набережная",
"наб",
"шоссе",
"ш",
"площадь",
"пл",
"тракт",
"аллея",
"тупик",
}
)
# Административные токены-маркеры: всё, что относится к городу/району/мкр — НЕ часть
# имени дома. Дропаем токен-маркер ВМЕСТЕ со следующим за ним словом-значением
# («р-н Октябрьский», «мкр Парковый», «г Екатеринбург»). Города ЕКБ-агломерации тоже
# чистим (стоят как ведущий токен «Екатеринбург,»/«Первоуральск,»).
_ADMIN_MARKER_TOKENS: frozenset[str] = frozenset(
{"рн", "р", "район", "мкр", "микрорайон", "г", "гор", "город", "обл", "область"}
)
_CITY_TOKENS: frozenset[str] = frozenset(
{"екатеринбург", "первоуральск", "березовский", "верхняя", "пышма", "среднеуральск", "россия"}
)
# Литера корпуса, прилипшая к номеру: «16а», «204г», «57а» → base + letter (рус/лат).
_HOUSE_LETTER_RE = re.compile(r"^(?P<num>\d+)\s*(?P<letter>[а-яa-z])?$", flags=re.UNICODE)
# Разбивка на токены: слова/числа, отбрасывая пунктуацию (',', '.', '·', '/', '-').
_TOKEN_SPLIT_RE = re.compile(r"[\s,;·]+", flags=re.UNICODE)
def _normalize_building_key(
address: str | None,
) -> tuple[str | None, int | None, str | None]:
"""Нормализует адрес в ключ «того же дома»: (street_alias, base_house_no, letter).
"""Нормализует адрес в robust-ключ «того же дома»: (street_core, base_no, letter).
- ёе, lowercase, отрезаем «г. Екатеринбург»/«ул.»/«пр.»/ (reuse extract_street_name).
- base_house_no = ведущее целое house-токена («204Г»/«4/2»/«9/1» 204/4/9).
- letter = опциональная литера корпуса (204**Г** 'г'); корпус «/N» отбрасываем
(один дом). Литеры РАЗНЫЕ дома в одном ЖК (8 Марта 204Г 204Д), поэтому
при наличии литеры у target матчим именно её (точнее, без ложного слипания).
- street прогоняется через _STREET_ALIAS_MAP (ТкачёваТкачей и т.д.).
Инвариантен к prod-форматам ЕКБ-вторички:
- ёе, lowercase.
- отрезает город / «р-н » / «мкр » / «г. » / «· »-хвост (district-suffix).
- дропает street-type слова ОТКУДА УГОДНО (улица/ул./наб./набережная/пр./
тип может стоять ДО или ПОСЛЕ имени: «улица Ткачей» И «Олимпийская наб.»);
остаток alpha-токенов = street_core («бориса ельцина», «сакко и ванцетти»).
- base_no = первый числовой токен ПОСЛЕ имени улицы, толерантно к
«Ткачей, 13» = «Ткачей,13» = «Ткачей,д. 13» = «Ткачей дом 13».
- letter = прилипшая литера корпуса («16а»'а', «204г»'г'); «/N» и «кN»
(корпус) схлопываются к base (тот же дом). Литеры РАЗНЫЕ дома (204г 204д).
- street_core прогоняется через _STREET_ALIAS_MAP (ткачеваткачей).
Returns (street_norm, base_no, letter) любой элемент None если не извлёкся.
Returns (street_core, base_no, letter) любой элемент None если не извлёкся.
Best-effort: при пустом адресе (None, None, None).
"""
if not address:
return None, None, None
norm = address.replace("ё", "е").replace("Ё", "Е")
street = extract_street_name(norm)
street_norm = street.strip().lower() if street else None
if street_norm:
street_norm = _STREET_ALIAS_MAP.get(street_norm, street_norm)
norm = address.replace("ё", "е").replace("Ё", "Е").lower()
# Отрезаем «· …»-хвост (Avito-формат «… · р-н Центр»): district всегда после «·».
norm = norm.split("·")[0]
# «д.»/«дом» перед номером → пробел (чтобы числовой токен встал отдельно).
norm = re.sub(r"\b(?:д\.?|дом)\s*(?=\d)", " ", norm, flags=re.UNICODE)
# Ищем house-токен ПОСЛЕ имени улицы (чтобы «8 Марта» не дало num=8 как дом).
tail = norm
if street:
idx = norm.lower().find(street.lower())
if idx >= 0:
tail = norm[idx + len(street) :]
raw = [t for t in _TOKEN_SPLIT_RE.split(norm) if t]
# 1. Вычищаем admin-маркеры (+следующее за ними значение) и города.
cleaned: list[str] = []
skip_next = False
for tok in raw:
if skip_next:
skip_next = False
continue
bare = tok.rstrip(".")
if bare in _ADMIN_MARKER_TOKENS:
skip_next = True # дропаем и сам маркер, и следующее слово-значение
continue
if bare in _CITY_TOKENS:
continue
cleaned.append(tok)
# 2. House-токен = ПОСЛЕДНИЙ токен, начинающийся с цифры. Берём последний (а не
# первый), чтобы числа ВНУТРИ имени улицы («8 Марта», «1905 года») не съелись
# как номер дома — настоящий номер всегда в хвосте, за именем. Хвост за номером
# (корпус «/N», «кN») игнорируем; всё остальное — токены улицы.
base_no: int | None = None
letter: str | None = None
m = _HOUSE_TOKEN_RE.search(tail)
if m:
base_no = int(m.group("num"))
letter = m.group("letter").lower() if m.group("letter") else None
return street_norm, base_no, letter
house_idx: int | None = None
for i, tok in enumerate(cleaned):
if tok[0].isdigit():
head = re.split(r"[/\\]", tok, maxsplit=1)[0] # «4/2» → «4»; корпус отброшен
head = re.split(r"к\d", head, maxsplit=1)[0] # «105к1» → «105»
m = _HOUSE_LETTER_RE.match(head)
if m:
base_no = int(m.group("num"))
letter = m.group("letter") or None
house_idx = i
# 3. street_core = токены до номера дома, минус type-слова (улица/наб./пр./…) и
# минус сам house-токен. Числовые префиксы имени («8 марта») сохраняем.
street_tokens = [
tok
for i, tok in enumerate(cleaned)
if i != house_idx and tok.rstrip(".") not in _STREET_TYPE_TOKENS
]
street_core = " ".join(street_tokens).strip() or None
if street_core:
street_core = _STREET_ALIAS_MAP.get(street_core, street_core)
return street_core, base_no, letter
def _fetch_anchor_comps(

View file

@ -59,6 +59,112 @@ def test_normalize_empty_address() -> None:
assert _normalize_building_key("") == (None, None, None)
# ── #677 hardening: real prod address-format variants → same key ─────────────
def test_normalize_tkachei13_all_db_variants_same_key() -> None:
"""Все prod-форматы «Ткачей 13» (запятая/«д.»/город/·-хвост/р-н/мкр) → ('ткачей', 13)."""
variants = [
"Екатеринбург, ул. Ткачёва, 13", # golden alias + ё
"Екатеринбург, улица Ткачей, 13",
"ул. Ткачей,13", # no space after comma
"ул. Ткачей,д. 13", # «д.» variant
"улица Ткачей, 13 · р-н Октябрьский", # ·-tail district
"р-н Октябрьский, мкр. Парковый, улица Ткачей, 13", # district + mkr prefix
]
for v in variants:
assert _normalize_building_key(v) == ("ткачей", 13, None), v
def test_normalize_house_13_not_130_or_131() -> None:
"""Дом 13 не должен совпасть с соседями 130/131 (разные base_no)."""
assert _normalize_building_key("улица Ткачей, 13")[1] == 13
assert _normalize_building_key("улица Ткачей, 130")[1] == 130
assert _normalize_building_key("улица Ткачей, 131")[1] == 131
def test_normalize_naberezhnaya_type_word_either_side() -> None:
"""Type-слово до ИЛИ после имени → один street_core «олимпийская»; наб.↔набережная."""
keys = {
_normalize_building_key("Олимпийская наб.,13"),
_normalize_building_key("набережная Олимпийская, 13"),
_normalize_building_key("Екатеринбург, Олимпийская набережная, 13"),
_normalize_building_key("р-н Центр, набережная Олимпийская, 13"),
}
assert keys == {("олимпийская", 13, None)}
def test_normalize_borisa_eltsina_multiword_street() -> None:
"""Многословное имя «Бориса Ельцина» сохраняется целиком; «д. 6»/город/·-хвост → 6."""
for v in (
"ул. Бориса Ельцина,д. 6",
"ул. Бориса Ельцина,6",
"Екатеринбург, улица Бориса Ельцина, 6",
"Бориса Ельцина, 6",
"улица Бориса Ельцина, 6 · р-н Центр",
):
assert _normalize_building_key(v) == ("бориса ельцина", 6, None), v
def test_normalize_sakko_multiword_with_conjunction() -> None:
"""«Сакко и Ванцетти» (с союзом) → стабильный core; 99 ≠ 100/105к1 (разные дома)."""
assert _normalize_building_key("ул. Сакко и Ванцетти,99") == ("сакко и ванцетти", 99, None)
assert _normalize_building_key("р-н Центр, улица Сакко и Ванцетти, 99") == (
"сакко и ванцетти",
99,
None,
)
assert _normalize_building_key("ул. Сакко и Ванцетти,100")[1] == 100 # separate building
assert _normalize_building_key("р-н Центр, улица Сакко и Ванцетти, 105к1") == (
"сакко и ванцетти",
105, # «105к1» корпус схлопнут к base
None,
)
def test_normalize_fevralskoy_revolyutsii_no_space_house() -> None:
"""«ул. Февральской Революции,21» (genitive multiword, no-space house) → core+21."""
assert _normalize_building_key("ул. Февральской Революции,21") == (
"февральской революции",
21,
None,
)
def test_normalize_8_marta_numeric_street_prefix() -> None:
"""Числовой префикс имени («8 Марта») НЕ съедается как номер дома — номер в хвосте."""
assert _normalize_building_key("Екатеринбург, улица 8 Марта, 204Г") == ("8 марта", 204, "г")
# без литеры
assert _normalize_building_key("улица 8 Марта, 50") == ("8 марта", 50, None)
def test_normalize_corpus_letter_distinct_from_plain() -> None:
"""Литера корпуса — РАЗНЫЕ дома: 16А (letter 'а') ≠ 16 (None)."""
assert _normalize_building_key("Екатеринбург, улица Ткачей, 16А") == ("ткачей", 16, "а")
assert _normalize_building_key("улица Ткачей, 16") == ("ткачей", 16, None)
def test_normalize_slash_corpus_collapses() -> None:
"""Корпус «/N» схлопывается к base (тот же дом): 9/1 → 9, 4/2 → 4, 19/2 → 19."""
assert _normalize_building_key("Олимпийская набережная, 9/1")[1] == 9
assert _normalize_building_key("Екатеринбург, ул. Мраморская, 4/2")[1] == 4
assert _normalize_building_key("улица Ткачей, 19/2")[1] == 19
def test_normalize_alias_applied_to_full_core_with_yo() -> None:
"""ё→е применяется ДО alias; alias матчит целый core (ткачёва→ткачева→ткачей)."""
assert _normalize_building_key("ул. Ткачёва, 13")[0] == "ткачей"
def test_normalize_no_house_number_returns_none_base() -> None:
"""Адрес без номера дома («р-н Центр, улица Бориса Ельцина») → base_no None (no match)."""
assert _normalize_building_key("р-н Центр, улица Бориса Ельцина") == (
"бориса ельцина",
None,
None,
)
# ── Pure anchor compute ─────────────────────────────────────────────────────