fix(tradein/estimator): дом «2» вместо «15» при глухом названии станции метро в avito-адресе #2429

Merged
lekss361 merged 1 commit from fix/tradein-dedup-2423-avito-metro-glue into main 2026-07-04 15:50:50 +00:00
2 changed files with 159 additions and 3 deletions
Showing only changes of commit 9a83b79d91 - Show all commits

View file

@ -4954,8 +4954,19 @@ _DEDUP_MKR_SUFFIX_RE = re.compile(
# Номер дома = первое число ПОСЛЕ названия улицы (опц. «д.»/«дом»), с литерой/
# корпусом («46», «8а», «12/3»). Ищется в остатке строки от конца street-матча —
# ведущая цифра нумерованной улицы («8 Марта») сюда не попадает.
# #2423: суффикс-буква («46г», «8а») засчитывается только если ЗА НЕЙ не идёт
# ЕЩЁ ОДИН word-символ (`(?!\w)` — буква ИЛИ цифра), т.е. только если это
# последний word-символ токена. Иначе это не литера дома, а: (а) первая буква
# приклеенного без пробела слова (название станции метро в avito:
# «2Чкаловская11-15 мин» → дом «2», а не «15» из хвоста дистанции), либо
# (б) буква корпуса, приклеенная без пробела к своей цифре («6к1» → дом «6»,
# ту же цифру даёт и вариант с пробелом «6 к1» — иначе кросс-source guard
# ошибочно видел бы «6к» vs «6» как разные дома и блокировал слияние).
# Trailing `\b` намеренно убран: он ложно "отменял" весь матч цифры, когда
# сразу за ней шло кириллическое слово без разделителя (Cyrillic letter и
# digit — оба `\w`, между ними нет word-boundary).
_DEDUP_HOUSE_NO_RE = re.compile(
r"\b(?:д(?:ом)?\.?\s*)?(\d+(?:[а-яё]|/\d+)?)\b",
r"\b(?:д(?:ом)?\.?\s*)?(\d+(?:[а-яё](?!\w)|/\d+)?)",
flags=re.IGNORECASE,
)
# Слова-маркеры дома, которые могли попасть в захват названия (напр. «ленина д 5»).

View file

@ -411,7 +411,12 @@ def test_dedup_empty_cadnum_and_street_stay_separate(monkeypatch: pytest.MonkeyP
[
# --- контрольные (существующая ветка «тип ПЕРЕД именем») — БАЙТ-В-БАЙТ
# тот же токен, что дают новые ветки, иначе клейка развалится.
("улица Рассветная, 6к1", ("рассветная", "")), # cian-стиль, house не извлекается
# #2423: «6к1» (cian-стиль, литера корпуса приклеена без пробела к
# цифре) теперь даёт house="6", а не "" — та же цифра, что и у
# пробельного «6 к1» ниже (домклик-стиль), т.е. извлечение стало
# согласованным между форматами (было: полный отказ матча из-за
# word-boundary collapse между цифрой и кириллической буквой).
("улица Рассветная, 6к1", ("рассветная", "6")),
("ул. Белинского 86", ("белинского", "86")),
("улица 8 Марта, 46", ("8 марта", "46")), # нумерованная, ведущая цифра в токене
# --- (а) имя ПЕРЕД типом (формат domklik)
@ -420,7 +425,8 @@ def test_dedup_empty_cadnum_and_street_stay_separate(monkeypatch: pytest.MonkeyP
("Красных Командиров улица, 10", ("красных командиров", "10")),
("8 Марта улица, 46", ("8 марта", "46")), # нумерованная, имя-перед-типом
# --- (б) тип-суффикс приклеен запятой/точкой без пробела (avito)
("Рассветная ул.,6к1", ("рассветная", "")),
# #2423: аналогично, house="6" вместо "" (см. комментарий выше).
("Рассветная ул.,6к1", ("рассветная", "6")),
("Белинского ул., 86", ("белинского", "86")),
# --- (в) fallback без уличного типа — микрорайон, префикс «mkr »
("Екатеринбург, Светлый микрорайон, 10", ("mkr светлый", "10")),
@ -637,3 +643,142 @@ def test_dedup_collapses_yo_ye_variant(monkeypatch: pytest.MonkeyPatch) -> None:
]
out = estimator._dedup_cross_source(lots)
assert len(out) == 1
# --------------------------------------------------------------------------- #
# #2423 avito glued-metro-name house-number extraction
# --------------------------------------------------------------------------- #
def test_parse_street_house_avito_glued_metro_suffix() -> None:
# Root cause прод-кейса: станция метро приклеена к номеру дома без
# разделителя («2Чкаловская»). Trailing `\b` в старом regex ложно отменял
# матч цифры целиком (Cyrillic-буква и цифра — оба `\w`, границы нет), и
# .search() уезжал вправо до «11-15» (дистанция до метро), где дефис даёт
# настоящую границу → дом ошибочно распознавался как «15» вместо «2».
addr = "ул. Амундсена, 2Чкаловская11-15 мин. пешком"
assert estimator._parse_street_house(addr) == ("амундсена", "2")
def test_parse_street_house_avito_glued_metro_hyphenated_name() -> None:
# Приклеенное название станции само содержит дефис — фикс не должен
# зависеть от того, есть ли внутри глухого слова дефис или нет.
addr = "ул. Космонавтов, 14Ботаническая-Уктус7-12 мин. пешком"
assert estimator._parse_street_house(addr) == ("космонавтов", "14")
def test_parse_street_house_avito_glued_metro_short_name() -> None:
# Адверсариальный минимум: приклеенное слово всего 2 буквы — lookahead
# должен отклонить суффикс-букву даже на короткой глухой строке, не
# только на длинных названиях станций.
addr = "ул. Мира, 9ВЗ5-8 мин. пешком"
assert estimator._parse_street_house(addr) == ("мира", "9")
@pytest.mark.parametrize(
("house_part", "expected_house"),
[
("24г", "24г"),
("32а", "32а"),
("5б", "5б"),
],
)
def test_parse_street_house_letter_suffix_not_regressed(
house_part: str, expected_house: str
) -> None:
# Настоящая литера дома (не продолжается ещё одной кириллической буквой,
# т.е. это последний word-символ токена) — поведение не должно измениться.
addr = f"ул. Ленина, {house_part}"
assert estimator._parse_street_house(addr) == ("ленина", expected_house)
def test_parse_street_house_corpus_slash_not_regressed() -> None:
# «12/3» (корпус через слэш) — вторая альтернатива (`/\d+`) не тронута фиксом.
assert estimator._parse_street_house("ул. Ленина, 12/3") == ("ленина", "12/3")
def test_parse_street_house_glued_corpus_letter_partial_capture() -> None:
# #2423: «10к1» без пробела перед литерой корпуса — литера «к» отклоняется
# как суффикс, т.к. за ней идёт ЕЩЁ ОДИН word-символ (цифра «1» корпуса),
# `(?!\w)` (не только `(?![а-яё])` из первоначального черновика фикса)
# отсекает и этот случай. Даёт «10» — тот же номер дома, что и у
# пробельного варианта «10 к1» (см. регрессию ниже) → house-guard видит
# одинаковый номер дома в обоих написаниях, кросс-source merge не ломается.
# ВАЖНО: черновой вариант фикса с `(?![а-яё])` (отклонять суффикс только
# если за ним ЕЩЁ ОДНА БУКВА) давал «10к» здесь — рассинхронизация с
# пробельным вариантом «6 к1» → «6» сломала 2 существующих e2e-теста
# (test_dedup_collapses_domklik_cian_name_order,
# test_dedup_collapses_domklik_cian_area_straddle), т.к. guard_house видел
# «6к» != «6» и блокировал слияние. Обнаружено прогоном полного набора
# тестов при реализации — `(?!\w)` устраняет регрессию.
assert estimator._parse_street_house("ул. Ленина, 10к1") == ("ленина", "10")
def test_parse_street_house_ambiguous_glued_digit_runs_limitation() -> None:
# #2423 limitation: два приклеенных друг к другу чисто-числовых прогона
# без буквы/пунктуации между ними принципиально неразличимы регэкспом —
# какое число дом, а какое дистанция, знает только семантика. В реальных
# avito-адресах между домом и дистанцией всегда есть «мин»/имя станции
# (см. тесты выше), так что этот кейс не встречается в проде; закрепляем
# текущий (не «исправленный») результат, чтобы будущее изменение было
# осознанным видимым диффом, а не тихой регрессией.
assert estimator._parse_street_house("ул. Ленина, 7500 м до метро") == ("ленина", "7500")
def test_dedup_collapses_avito_glued_metro_suffix_house_match(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# e2e: avito-лот с приклеенным названием станции (дом «2» до фикса терялся,
# извлекалось «15») + чистый cian-адрес того же дома «2» → house-guard
# больше не блокирует street-union (оба извлекают «2») → один физлот.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
source="avito",
address="ул. Амундсена, 2Чкаловская11-15 мин. пешком",
area=45.0,
price=6_100_000,
floor=5,
cad=None,
),
_lot(
source="cian",
address="ул. Амундсена, 2",
area=45.0,
price=6_100_000,
floor=5,
cad=None,
),
]
out = estimator._dedup_cross_source(lots)
assert len(out) == 1
def test_dedup_glued_metro_noise_does_not_cause_over_merge(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Анти-регрессия: фикс не должен сделать guard_house снисходительнее для
# адресов с глухим метро-шумом в общем случае — ДЕЙСТВИТЕЛЬНО разные дома
# («24» vs «26»), каждый одет в приклеенное название станции разной длины,
# ДОЛЖНЫ остаться раздельными (guard всё ещё блокирует both-present-different).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
source="avito",
address="ул. Гагарина, 24Чкаловская5-8 мин. пешком",
area=45.0,
price=6_100_000,
floor=5,
cad=None,
),
_lot(
source="cian",
address="ул. Гагарина, 26Ботаническая11-15 мин. пешком",
area=45.0,
price=6_100_000,
floor=5,
cad=None,
),
]
out = estimator._dedup_cross_source(lots)
assert len(out) == 2