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
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:
parent
c30b3c714e
commit
b5c26d2723
2 changed files with 216 additions and 29 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue