fix(tradein/estimator): восстановить и канонизировать корпус дома в дедупе (регрессия #2429) #2432

Merged
lekss361 merged 1 commit from fix/tradein-dedup-corpus-format-normalize into main 2026-07-04 17:08:40 +00:00
2 changed files with 257 additions and 38 deletions

View file

@ -4952,21 +4952,30 @@ _DEDUP_MKR_SUFFIX_RE = re.compile(
flags=re.IGNORECASE,
)
# Номер дома = первое число ПОСЛЕ названия улицы (опц. «д.»/«дом»), с литерой/
# корпусом («46», «8а», «12/3»). Ищется в остатке строки от конца street-матча —
# ведущая цифра нумерованной улицы («8 Марта») сюда не попадает.
# корпусом («46», «8а», «12/3», «12к3»). Ищется в остатке строки от конца
# street-матча — ведущая цифра нумерованной улицы («8 Марта») сюда не попадает.
# #2423: суффикс-буква («46г», «8а») засчитывается только если ЗА НЕЙ не идёт
# ЕЩЁ ОДИН word-символ (`(?!\w)` — буква ИЛИ цифра), т.е. только если это
# последний word-символ токена. Иначе это не литера дома, а: (а) первая буква
# последний word-символ токена. Иначе это не литера дома, а первая буква
# приклеенного без пробела слова (название станции метро в avito:
# «2Чкаловская11-15 мин» → дом «2», а не «15» из хвоста дистанции), либо
# (б) буква корпуса, приклеенная без пробела к своей цифре («6к1» → дом «6»,
# ту же цифру даёт и вариант с пробелом «6 к1» — иначе кросс-source guard
# ошибочно видел бы «6к» vs «6» как разные дома и блокировал слияние).
# «2Чкаловская11-15 мин» → дом «2», а не «15» из хвоста дистанции).
# #2436 (фикс регрессии #2429): корпус-словоформа («к»/«корп»/«корпус», глухо
# ИЛИ с пробелом, ЗА КОТОРОЙ идёт цифра корпуса) — отдельная альтернатива,
# пробуется ДО bare-letter-альтернативы, чтобы захватить ВЕСЬ токен «65к4»/
# «65 к4» целиком (а не только «65», как раньше — #2429 отклонял букву через
# `(?!\w)`, но не восстанавливал отброшенную цифру корпуса). Раздельные
# source-нотации корпуса («65к4» avito/cian glued, «65/4» domklik slash)
# канонизируются ПОСЛЕ этого regex-а через `_normalize_house_no` (см. ниже) —
# сам regex лишь ГАРАНТИРУЕТ, что сырая корпус-подстрока захвачена целиком, не
# усечена; реформатирование в единый «N/M» — забота нормализатора.
# Trailing `\b` намеренно убран: он ложно "отменял" весь матч цифры, когда
# сразу за ней шло кириллическое слово без разделителя (Cyrillic letter и
# digit — оба `\w`, между ними нет word-boundary).
_DEDUP_HOUSE_NO_RE = re.compile(
r"\b(?:д(?:ом)?\.?\s*)?(\d+(?:[а-яё](?!\w)|/\d+)?)",
r"\b(?:д(?:ом)?\.?\s*)?"
r"(\d+(?:\s*/\s*\d+"
r"|\s*,?\s*к(?:орп(?:ус)?)?\.?\s*\d+"
r"|[а-яё](?!\w))?)",
flags=re.IGNORECASE,
)
# Слова-маркеры дома, которые могли попасть в захват названия (напр. «ленина д 5»).
@ -5058,6 +5067,41 @@ def _bare_street_before_house_segment(s: str) -> tuple[str, str] | None:
return None
# #2436: канонизация house_no к единому кросс-source формату. Регекс выше лишь
# ГАРАНТИРУЕТ, что сырая корпус-подстрока захвачена целиком («65к4», «65 к4»,
# «65, корпус 4») — реформатирование в один канонический «N/M» делает функция
# ниже, а не сам regex (три разные лексические словоформы корпуса плюс слэш —
# читаемее и тестируемее как обычный Python, чем regex-replacement).
_CORPUS_SLASH_RE = re.compile(r"^(\d+)\s*/\s*(\d+)$")
_CORPUS_WORD_RE = re.compile(r"^(\d+)\s*,?\s*к(?:орп(?:ус)?)?\.?\s*(\d+)$", re.IGNORECASE)
_LITER_RE = re.compile(r"^(\d+)([а-яё])$", re.IGNORECASE)
def _normalize_house_no(raw: str) -> str:
"""Канонизирует извлечённый house_no к единому кросс-source формату.
Корпус ЛЮБАЯ нотация («65к4», «65 к4», «65, корпус 4», «65/4»)
канонизируется к «65/4», чтобы guard_house's string equality (#2429
регрессия) видела один и тот же дом независимо от того, как его записал
источник (avito/cian глухая/словесная нотация; domklik слэш). Литер
(единственная буква БЕЗ цифры за ней, «24г») другой физический суффикс
(«дом 24, литер Г», не «дом 24 корпус 4») и НЕ нормализуется остаётся
«24г». Идемпотентна: вызов на уже-канонической строке no-op. Не
полагается на то, что `raw` уже приведён к нижнему регистру вызывающей
стороной регексы сами case-insensitive.
"""
if not raw:
return raw
s = raw.strip()
m = _CORPUS_SLASH_RE.match(s) or _CORPUS_WORD_RE.match(s)
if m:
return f"{m.group(1)}/{m.group(2)}"
m = _LITER_RE.match(s)
if m:
return f"{m.group(1)}{m.group(2).lower()}"
return s
def _parse_street_house(addr: str | None) -> tuple[str, str]:
"""(street_token, house_no) из адреса произвольного source-формата (#2265).
@ -5087,7 +5131,8 @@ def _parse_street_house(addr: str | None) -> tuple[str, str]:
# _bare_street_before_house_segment).
bare = _bare_street_before_house_segment(s)
if bare is not None:
return bare
bare_street, bare_house = bare
return bare_street, _normalize_house_no(bare_house)
if m is None:
# Ветка 3 (#2269 в): без уличного типа — микрорайон-fallback. Токен
# получает префикс «mkr », чтобы не пересекаться с уличными токенами.
@ -5108,7 +5153,7 @@ def _parse_street_house(addr: str | None) -> tuple[str, str]:
house = ""
hm = _DEDUP_HOUSE_NO_RE.search(s, m.end())
if hm:
house = hm.group(1)
house = _normalize_house_no(hm.group(1))
return street, house

View file

@ -411,22 +411,23 @@ def test_dedup_empty_cadnum_and_street_stay_separate(monkeypatch: pytest.MonkeyP
[
# --- контрольные (существующая ветка «тип ПЕРЕД именем») — БАЙТ-В-БАЙТ
# тот же токен, что дают новые ветки, иначе клейка развалится.
# #2423: «6к1» (cian-стиль, литера корпуса приклеена без пробела к
# цифре) теперь даёт house="6", а не "" — та же цифра, что и у
# пробельного «6 к1» ниже (домклик-стиль), т.е. извлечение стало
# согласованным между форматами (было: полный отказ матча из-за
# word-boundary collapse между цифрой и кириллической буквой).
("улица Рассветная, 6к1", ("рассветная", "6")),
# #2423 (частично устарело после #2436): «6к1» (cian-стиль, литера
# корпуса приклеена без пробела к цифре) даёт house — тот же canonical
# «6/1», что и у пробельного «6 к1» ниже (домклик-стиль). #2423 сам по
# себе лишь остановил ложный захват буквы (`(?!\w)`), но давал
# усечённое «6» — #2436 добавил `_normalize_house_no`, восстанавливающий
# отброшенную цифру корпуса и канонизирующий её к слэш-форме.
("улица Рассветная, 6к1", ("рассветная", "6/1")),
("ул. Белинского 86", ("белинского", "86")),
("улица 8 Марта, 46", ("8 марта", "46")), # нумерованная, ведущая цифра в токене
# --- (а) имя ПЕРЕД типом (формат domklik)
("Рассветная улица, 6 к1", ("рассветная", "6")),
("Рассветная улица, 6 к1", ("рассветная", "6/1")),
("Россия, Екатеринбург, Рассветная улица, 6", ("рассветная", "6")),
("Красных Командиров улица, 10", ("красных командиров", "10")),
("8 Марта улица, 46", ("8 марта", "46")), # нумерованная, имя-перед-типом
# --- (б) тип-суффикс приклеен запятой/точкой без пробела (avito)
# #2423: аналогично, house="6" вместо "" (см. комментарий выше).
("Рассветная ул.,6к1", ("рассветная", "6")),
# #2436: канонизируется к «6/1» — тот же токен, что у веток (а)/контрольной.
("Рассветная ул.,6к1", ("рассветная", "6/1")),
("Белинского ул., 86", ("белинского", "86")),
# --- (в) fallback без уличного типа — микрорайон, префикс «mkr »
("Екатеринбург, Светлый микрорайон, 10", ("mkr светлый", "10")),
@ -467,7 +468,10 @@ def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch
# Живой пример несклейки (оценка 23b8ada1): domklik «Рассветная улица, 6 к1»
# + cian «улица Рассветная, 6к1» — тот же физлот (эт.13, ~38.5 м², 6.599М),
# cadnum пуст (domklik всегда NULL). Расширенный парсер даёт обоим токен
# «рассветная» → union-find по street-композиту схлопывает в один.
# «рассветная» → union-find по street-композиту схлопывает в один. Оба
# адреса используют одну и ту же глухую («к1») family корпуса, и после
# #2436 нормализуются к одинаковой «6/1» — house-guard видит равные номера
# независимо от пробела перед «к» (см. _normalize_house_no).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
@ -499,7 +503,9 @@ def test_dedup_collapses_domklik_cian_area_straddle(monkeypatch: pytest.MonkeyPa
# СОСЕДНИЕ area_bucket банкирским round() — 38.5 → 38 (округление к чётному),
# 39.0 → 39. До фикса разные area_bucket → street-owner lookup никогда не
# находил друг друга → лот считался дважды. ±1-bucket допуск на street-union
# должен склеить их в один физлот.
# должен склеить их в один физлот. Оба адреса — глухая («к1») family
# корпуса → после #2436 нормализуются к одинаковой «6/1» (см.
# _normalize_house_no), house-guard не блокирует merge независимо от пробела.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
@ -527,8 +533,8 @@ def test_dedup_collapses_domklik_cian_area_straddle(monkeypatch: pytest.MonkeyPa
def test_dedup_collapses_avito_glued_suffix(monkeypatch: pytest.MonkeyPatch) -> None:
# (б) avito с глухим суффиксом «Рассветная ул.,6к1» + cian «улица Рассветная,
# 6к1» → тот же токен «рассветная», house не извлекается ни у одного (guard
# equal-or-missing) → склейка.
# 6к1» → тот же токен «рассветная», house у обоих канонизируется в «6/1»
# (см. #2436 _normalize_house_no) → guard видит равные номера → склейка.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(source="avito", address="Рассветная ул.,6к1", area=38.5, price=6_599_000, floor=13),
@ -695,23 +701,24 @@ def test_parse_street_house_letter_suffix_not_regressed(
def test_parse_street_house_corpus_slash_not_regressed() -> None:
# «12/3» (корпус через слэш) — вторая альтернатива (`/\d+`) не тронута фиксом.
assert estimator._parse_street_house("ул. Ленина, 12/3") == ("ленина", "12/3")
# #2436: глухая нотация того же корпуса («12к3») канонизируется к ТОЙ ЖЕ
# «12/3» — кросс-нотационная эквивалентность закреплена на unit-уровне, не
# только в e2e dedup-тесте (см. test_parse_street_house_all_corpus_notations_agree).
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_glued_corpus_canonicalizes_to_slash() -> None:
# #2436 fix (регрессия post-#2429): «10к1» (avito/cian глухая нотация
# корпуса) должна канонизироваться к ТОЙ ЖЕ «N/M» строке, что и родная
# slash-нотация domklik «10/1» — иначе guard_house's plain string equality
# (#2429 регрессия, 226 дублирующих групп на проде 2026-07-04) видит
# «10» != «10/1» и блокирует кросс-source слияние того же физического
# здания.
# Superseded assumption (до этого фикса): этот тест раньше утверждал
# «10к1» -> «10», т.е. цифра корпуса молча отбрасывалась — это и был баг,
# а не спецификация.
assert estimator._parse_street_house("ул. Ленина, 10к1") == ("ленина", "10/1")
assert estimator._parse_street_house("ул. Ленина, 10 к1") == ("ленина", "10/1")
def test_parse_street_house_ambiguous_glued_digit_runs_limitation() -> None:
@ -782,3 +789,170 @@ def test_dedup_glued_metro_noise_does_not_cause_over_merge(
]
out = estimator._dedup_cross_source(lots)
assert len(out) == 2
# --------------------------------------------------------------------------- #
# #2436 — house-number corpus normalization (post-#2429 regression fix)
#
# #2429 stopped the wrong-letter-capture false-negative-match problem, but
# left the corpus DIGIT itself silently dropped for glued/spaced notations
# («65к4»/«65 к4» -> «65», not «65/4») — guard_house's plain string equality
# then saw «65» (avito/cian) != «65/4» (domklik) for the SAME physical
# building and blocked the cross-source merge (226 dup-groups in prod
# 2026-07-04). `_normalize_house_no` canonicalizes every corpus notation to
# a single «N/M» token; genuine liters («24г») are left untouched.
# --------------------------------------------------------------------------- #
def test_normalize_house_no_glued_corpus_to_slash() -> None:
assert estimator._normalize_house_no("65к4") == "65/4"
def test_normalize_house_no_spaced_corpus_to_slash() -> None:
assert estimator._normalize_house_no("65 к4") == "65/4"
assert estimator._normalize_house_no("65 к 4") == "65/4"
def test_normalize_house_no_word_corpus_to_slash() -> None:
assert estimator._normalize_house_no("65, корпус 4") == "65/4"
assert estimator._normalize_house_no("65 корп.4") == "65/4"
# Case-insensitivity — defensive: _parse_street_house обычно уже приводит
# к нижнему регистру, но сам helper не должен полагаться на этот инвариант.
assert estimator._normalize_house_no("65 КОРПУС 4") == "65/4"
def test_normalize_house_no_slash_is_idempotent() -> None:
assert estimator._normalize_house_no("65/4") == "65/4"
def test_normalize_house_no_liter_untouched() -> None:
# Литер («24г») — другой физический суффикс («дом 24, литер Г»), НЕ
# схлопывается в слэш-форму и НЕ обрезается до голой цифры.
assert estimator._normalize_house_no("24г") == "24г"
@pytest.mark.parametrize(
"notation",
["65к4", "65 к4", "65, корпус 4", "65/4"],
)
def test_parse_street_house_all_corpus_notations_agree(notation: str) -> None:
# Все четыре реальные source-нотации одного и того же корпуса резолвятся
# в ОДИН и тот же canonical house_no — кросс-source guard видит равенство
# независимо от того, как источник записал корпус.
assert estimator._parse_street_house(f"ул. Ленина, {notation}") == ("ленина", "65/4")
def test_dedup_collapses_avito_glued_vs_domklik_slash_corpus(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Точный репортированный баг: avito-подобный адрес («...65к4...», глухая
# нотация) + domklik-подобный адрес («...65/4...», slash-нотация) — тот же
# физлот, без кадастра ни у кого, тот же этаж/площадь/цена-бакет — ДОЛЖНЫ
# схлопнуться в один. union() зависит от порядка (кто станет root) —
# проверяем ОБА порядка следования лотов.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
def _pair(first_source: str, second_source: str) -> list[dict]:
by_source = {
"avito": _lot(
source="avito",
address="ул. Ленина, 65к4",
area=54.0,
price=7_200_000,
floor=9,
cad=None,
scraped_at=datetime(2026, 7, 1, tzinfo=UTC),
),
"domklik": _lot(
source="domklik",
address="ул. Ленина, 65/4",
area=54.0,
price=7_200_000,
floor=9,
cad=None,
scraped_at=datetime(2026, 7, 3, tzinfo=UTC),
),
}
return [by_source[first_source], by_source[second_source]]
out_avito_first = estimator._dedup_cross_source(_pair("avito", "domklik"))
assert len(out_avito_first) == 1
out_domklik_first = estimator._dedup_cross_source(_pair("domklik", "avito"))
assert len(out_domklik_first) == 1
def test_dedup_liter_suffix_still_blocks_merge_from_different_corpus(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Адверсариальный кейс: нормализация НЕ должна расширять сеть слияний за
# пределы genuine corpus-notation эквивалентности. «65к4» (-> «65/4») и
# «65г» (литер, не тронут) — РАЗНЫЕ дома на одной улице, слияния быть не
# должно.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
source="avito",
address="ул. Ленина, 65к4",
area=54.0,
price=7_200_000,
floor=9,
cad=None,
),
_lot(
source="cian",
address="ул. Ленина, 65г",
area=54.0,
price=7_200_000,
floor=9,
cad=None,
),
]
out = estimator._dedup_cross_source(lots)
assert len(out) == 2
def test_dedup_house_no_guard_blocks_different_corpus_numbers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Нормализация — не blanket same-street merge: «65/4» vs «65/5» (или
# «65к4» vs «65к5») — genuinely разные здания, guard_house обязан
# по-прежнему их разделять после нормализации.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
source="domklik",
address="ул. Ленина, 65/4",
area=54.0,
price=7_200_000,
floor=9,
cad=None,
),
_lot(
source="cian",
address="ул. Ленина, 65/5",
area=54.0,
price=7_200_000,
floor=9,
cad=None,
),
]
out = estimator._dedup_cross_source(lots)
assert len(out) == 2
def test_parse_street_house_apartment_marker_not_confused_with_corpus() -> None:
# Edge case: «кв.»/«квартира» (маркер квартиры) тоже начинается с буквы
# «к» — corpus-word ветка (`к(?:орп(?:ус)?)?`) НЕ должна на неё сработать,
# т.к. паттерн требует цифру СРАЗУ после буквенного корпус-маркера, а
# «кв. 12» между «к» и «12» содержит «в. » — не матчится. Дом = «65», а
# не «65в12»/сфабрикованный корпус.
assert estimator._parse_street_house("ул. Ленина, 65, кв. 12") == ("ленина", "65")
def test_parse_street_house_dangling_corpus_letter_no_digit_is_liter() -> None:
# Edge case: «65к» без цифры корпуса ВООБЩЕ (усечённый/битый маркер, либо
# редкая литера «К») — консервативно трактуется как литер (нет цифры
# сразу после буквы ⇒ литер, см. design §3), остаётся «65к» — НЕ
# схлопывается ни во что и не отбрасывается.
assert estimator._parse_street_house("ул. Ленина, 65к") == ("ленина", "65к")