fix(tradein/estimator): восстановить и канонизировать корпус дома в дедупе (#2436)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 50s

Регрессия post-#2429: _DEDUP_HOUSE_NO_RE захватывал целиком только слэш-нотацию
корпуса ("65/4"), а глухую ("65к4") и с пробелом ("65 к4") усекал до голой
цифры ("65") — #2429 лишь остановил ложный захват буквы через `(?!\w)`, но не
восстановил отброшенную цифру корпуса. guard_house в union() затем сравнивал
"65" (avito/cian) != "65/4" (domklik) для ОДНОГО и того же физического дома и
блокировал кросс-source слияние — 226 дублирующих групп в проде.

Фикс:
- _DEDUP_HOUSE_NO_RE: добавлена альтернатива для корпус-словоформы
  (к/корп/корпус, глухо или с пробелом, ТРЕБУЕТ цифру корпуса сразу за собой) —
  пробуется ДО bare-letter альтернативы, чтобы "65к4" захватывался целиком, а
  не усекался.
- Новая _normalize_house_no(): канонизирует ЛЮБУЮ нотацию корпуса ("65к4",
  "65 к4", "65, корпус 4", "65/4") к единому "65/4". Литер без цифры за буквой
  ("24г") — другой физический суффикс, НЕ трогается. Идемпотентна.
- Вызывается в единой точке — оба return _parse_street_house (включая
  bare-street ветку #2291) — так что guard_house всегда видит канонические
  значения независимо от source-формата.

Тесты: rewrite test_parse_street_house_glued_corpus_letter_partial_capture
(пиновал баг "10к1" -> "10" как норму) в
test_parse_street_house_glued_corpus_canonicalizes_to_slash ("10к1"/"10 к1" ->
"10/1"); обновлены 3 существующих кейса в test_parse_street_house_extended_formats
и stale-комментарии в 2 e2e тестах; +14 новых тестов (5 unit normalize_house_no,
parametrized cross-notation agreement, e2e slash-vs-glued merge в обе стороны
порядка union(), adversarial liter-vs-corpus non-merge, guard всё ещё блокирует
разные номера корпуса, apartment-marker "кв." не путается с "корп.", dangling
"65к" без цифры = литер).

Live-верификация на проде (read-only, house_id_fk+floor+area+price кластеры,
count(DISTINCT source)>1): 4841 кандидат-кластера / 11756 строк. OLD-логика
(текущий прод, post-#2429) 5441 survivor-строк, NEW (этот фикс) 5261 —
186 кластеров изменились, 185 полностью схлопнулись в 1 представителя (192-й
частично: 4->3). Zero true guard-violations — автоматическая проверка по всем
4841 кластерам подтвердила, что ни один мёрдж не объединил лоты с ГЕНУИННО
разными (после нормализации) номерами корпуса без авторитетного совпадения
кадастрового номера. 6 кластеров показали ПРОТИВОПОЛОЖНЫЙ эффект (было 1
survivor, стало 2) — во всех 6 один источник (обычно domklik) даёт "голый"
номер дома без корпуса ("77"), а другой — конкретный корпус ("77к1"/"77к4");
раньше баг СЛУЧАЙНО делал их равными через усечение, сейчас guard корректно
их не сливает (та же консервативная семантика "genuinely-different -> не
сливаем", что уже применяется к "65/4" vs "65/5"); отдельный,
architecturally-orthogonal вопрос (стоит ли считать голый номер wildcard'ом
для любого корпуса) — вне scope этого фикса, не блокирует.
This commit is contained in:
bot-backend 2026-07-04 19:50:52 +03:00
parent 752c3656a3
commit deffd31156
2 changed files with 257 additions and 38 deletions

View file

@ -4952,21 +4952,30 @@ _DEDUP_MKR_SUFFIX_RE = re.compile(
flags=re.IGNORECASE, flags=re.IGNORECASE,
) )
# Номер дома = первое число ПОСЛЕ названия улицы (опц. «д.»/«дом»), с литерой/ # Номер дома = первое число ПОСЛЕ названия улицы (опц. «д.»/«дом»), с литерой/
# корпусом («46», «8а», «12/3»). Ищется в остатке строки от конца street-матча — # корпусом («46», «8а», «12/3», «12к3»). Ищется в остатке строки от конца
# ведущая цифра нумерованной улицы («8 Марта») сюда не попадает. # street-матча — ведущая цифра нумерованной улицы («8 Марта») сюда не попадает.
# #2423: суффикс-буква («46г», «8а») засчитывается только если ЗА НЕЙ не идёт # #2423: суффикс-буква («46г», «8а») засчитывается только если ЗА НЕЙ не идёт
# ЕЩЁ ОДИН word-символ (`(?!\w)` — буква ИЛИ цифра), т.е. только если это # ЕЩЁ ОДИН word-символ (`(?!\w)` — буква ИЛИ цифра), т.е. только если это
# последний word-символ токена. Иначе это не литера дома, а: (а) первая буква # последний word-символ токена. Иначе это не литера дома, а первая буква
# приклеенного без пробела слова (название станции метро в avito: # приклеенного без пробела слова (название станции метро в avito:
# «2Чкаловская11-15 мин» → дом «2», а не «15» из хвоста дистанции), либо # «2Чкаловская11-15 мин» → дом «2», а не «15» из хвоста дистанции).
# (б) буква корпуса, приклеенная без пробела к своей цифре («6к1» → дом «6», # #2436 (фикс регрессии #2429): корпус-словоформа («к»/«корп»/«корпус», глухо
# ту же цифру даёт и вариант с пробелом «6 к1» — иначе кросс-source guard # ИЛИ с пробелом, ЗА КОТОРОЙ идёт цифра корпуса) — отдельная альтернатива,
# ошибочно видел бы «6к» vs «6» как разные дома и блокировал слияние). # пробуется ДО 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` намеренно убран: он ложно "отменял" весь матч цифры, когда # Trailing `\b` намеренно убран: он ложно "отменял" весь матч цифры, когда
# сразу за ней шло кириллическое слово без разделителя (Cyrillic letter и # сразу за ней шло кириллическое слово без разделителя (Cyrillic letter и
# digit — оба `\w`, между ними нет word-boundary). # digit — оба `\w`, между ними нет word-boundary).
_DEDUP_HOUSE_NO_RE = re.compile( _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, flags=re.IGNORECASE,
) )
# Слова-маркеры дома, которые могли попасть в захват названия (напр. «ленина д 5»). # Слова-маркеры дома, которые могли попасть в захват названия (напр. «ленина д 5»).
@ -5058,6 +5067,41 @@ def _bare_street_before_house_segment(s: str) -> tuple[str, str] | None:
return 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]: def _parse_street_house(addr: str | None) -> tuple[str, str]:
"""(street_token, house_no) из адреса произвольного source-формата (#2265). """(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_street_before_house_segment).
bare = _bare_street_before_house_segment(s) bare = _bare_street_before_house_segment(s)
if bare is not None: if bare is not None:
return bare bare_street, bare_house = bare
return bare_street, _normalize_house_no(bare_house)
if m is None: if m is None:
# Ветка 3 (#2269 в): без уличного типа — микрорайон-fallback. Токен # Ветка 3 (#2269 в): без уличного типа — микрорайон-fallback. Токен
# получает префикс «mkr », чтобы не пересекаться с уличными токенами. # получает префикс «mkr », чтобы не пересекаться с уличными токенами.
@ -5108,7 +5153,7 @@ def _parse_street_house(addr: str | None) -> tuple[str, str]:
house = "" house = ""
hm = _DEDUP_HOUSE_NO_RE.search(s, m.end()) hm = _DEDUP_HOUSE_NO_RE.search(s, m.end())
if hm: if hm:
house = hm.group(1) house = _normalize_house_no(hm.group(1))
return street, house 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-стиль, литера корпуса приклеена без пробела к # #2423 (частично устарело после #2436): «6к1» (cian-стиль, литера
# цифре) теперь даёт house="6", а не "" — та же цифра, что и у # корпуса приклеена без пробела к цифре) даёт house — тот же canonical
# пробельного «6 к1» ниже (домклик-стиль), т.е. извлечение стало # «6/1», что и у пробельного «6 к1» ниже (домклик-стиль). #2423 сам по
# согласованным между форматами (было: полный отказ матча из-за # себе лишь остановил ложный захват буквы (`(?!\w)`), но давал
# word-boundary collapse между цифрой и кириллической буквой). # усечённое «6» — #2436 добавил `_normalize_house_no`, восстанавливающий
("улица Рассветная, 6к1", ("рассветная", "6")), # отброшенную цифру корпуса и канонизирующий её к слэш-форме.
("улица Рассветная, 6к1", ("рассветная", "6/1")),
("ул. Белинского 86", ("белинского", "86")), ("ул. Белинского 86", ("белинского", "86")),
("улица 8 Марта, 46", ("8 марта", "46")), # нумерованная, ведущая цифра в токене ("улица 8 Марта, 46", ("8 марта", "46")), # нумерованная, ведущая цифра в токене
# --- (а) имя ПЕРЕД типом (формат domklik) # --- (а) имя ПЕРЕД типом (формат domklik)
("Рассветная улица, 6 к1", ("рассветная", "6")), ("Рассветная улица, 6 к1", ("рассветная", "6/1")),
("Россия, Екатеринбург, Рассветная улица, 6", ("рассветная", "6")), ("Россия, Екатеринбург, Рассветная улица, 6", ("рассветная", "6")),
("Красных Командиров улица, 10", ("красных командиров", "10")), ("Красных Командиров улица, 10", ("красных командиров", "10")),
("8 Марта улица, 46", ("8 марта", "46")), # нумерованная, имя-перед-типом ("8 Марта улица, 46", ("8 марта", "46")), # нумерованная, имя-перед-типом
# --- (б) тип-суффикс приклеен запятой/точкой без пробела (avito) # --- (б) тип-суффикс приклеен запятой/точкой без пробела (avito)
# #2423: аналогично, house="6" вместо "" (см. комментарий выше). # #2436: канонизируется к «6/1» — тот же токен, что у веток (а)/контрольной.
("Рассветная ул.,6к1", ("рассветная", "6")), ("Рассветная ул.,6к1", ("рассветная", "6/1")),
("Белинского ул., 86", ("белинского", "86")), ("Белинского ул., 86", ("белинского", "86")),
# --- (в) fallback без уличного типа — микрорайон, префикс «mkr » # --- (в) fallback без уличного типа — микрорайон, префикс «mkr »
("Екатеринбург, Светлый микрорайон, 10", ("mkr светлый", "10")), ("Екатеринбург, Светлый микрорайон, 10", ("mkr светлый", "10")),
@ -467,7 +468,10 @@ def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch
# Живой пример несклейки (оценка 23b8ada1): domklik «Рассветная улица, 6 к1» # Живой пример несклейки (оценка 23b8ada1): domklik «Рассветная улица, 6 к1»
# + cian «улица Рассветная, 6к1» — тот же физлот (эт.13, ~38.5 м², 6.599М), # + cian «улица Рассветная, 6к1» — тот же физлот (эт.13, ~38.5 м², 6.599М),
# cadnum пуст (domklik всегда NULL). Расширенный парсер даёт обоим токен # 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) monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [ lots = [
_lot( _lot(
@ -499,7 +503,9 @@ def test_dedup_collapses_domklik_cian_area_straddle(monkeypatch: pytest.MonkeyPa
# СОСЕДНИЕ area_bucket банкирским round() — 38.5 → 38 (округление к чётному), # СОСЕДНИЕ area_bucket банкирским round() — 38.5 → 38 (округление к чётному),
# 39.0 → 39. До фикса разные area_bucket → street-owner lookup никогда не # 39.0 → 39. До фикса разные area_bucket → street-owner lookup никогда не
# находил друг друга → лот считался дважды. ±1-bucket допуск на street-union # находил друг друга → лот считался дважды. ±1-bucket допуск на street-union
# должен склеить их в один физлот. # должен склеить их в один физлот. Оба адреса — глухая («к1») family
# корпуса → после #2436 нормализуются к одинаковой «6/1» (см.
# _normalize_house_no), house-guard не блокирует merge независимо от пробела.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [ lots = [
_lot( _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: def test_dedup_collapses_avito_glued_suffix(monkeypatch: pytest.MonkeyPatch) -> None:
# (б) avito с глухим суффиксом «Рассветная ул.,6к1» + cian «улица Рассветная, # (б) avito с глухим суффиксом «Рассветная ул.,6к1» + cian «улица Рассветная,
# 6к1» → тот же токен «рассветная», house не извлекается ни у одного (guard # 6к1» → тот же токен «рассветная», house у обоих канонизируется в «6/1»
# equal-or-missing) → склейка. # (см. #2436 _normalize_house_no) → guard видит равные номера → склейка.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True) monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [ lots = [
_lot(source="avito", address="Рассветная ул.,6к1", area=38.5, price=6_599_000, floor=13), _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: def test_parse_street_house_corpus_slash_not_regressed() -> None:
# «12/3» (корпус через слэш) — вторая альтернатива (`/\d+`) не тронута фиксом. # «12/3» (корпус через слэш) — вторая альтернатива (`/\d+`) не тронута фиксом.
assert estimator._parse_street_house("ул. Ленина, 12/3") == ("ленина", "12/3") 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: def test_parse_street_house_glued_corpus_canonicalizes_to_slash() -> None:
# #2423: «10к1» без пробела перед литерой корпуса — литера «к» отклоняется # #2436 fix (регрессия post-#2429): «10к1» (avito/cian глухая нотация
# как суффикс, т.к. за ней идёт ЕЩЁ ОДИН word-символ (цифра «1» корпуса), # корпуса) должна канонизироваться к ТОЙ ЖЕ «N/M» строке, что и родная
# `(?!\w)` (не только `(?![а-яё])` из первоначального черновика фикса) # slash-нотация domklik «10/1» — иначе guard_house's plain string equality
# отсекает и этот случай. Даёт «10» — тот же номер дома, что и у # (#2429 регрессия, 226 дублирующих групп на проде 2026-07-04) видит
# пробельного варианта «10 к1» (см. регрессию ниже) → house-guard видит # «10» != «10/1» и блокирует кросс-source слияние того же физического
# одинаковый номер дома в обоих написаниях, кросс-source merge не ломается. # здания.
# ВАЖНО: черновой вариант фикса с `(?![а-яё])` (отклонять суффикс только # Superseded assumption (до этого фикса): этот тест раньше утверждал
# если за ним ЕЩЁ ОДНА БУКВА) давал «10к» здесь — рассинхронизация с # «10к1» -> «10», т.е. цифра корпуса молча отбрасывалась — это и был баг,
# пробельным вариантом «6 к1» → «6» сломала 2 существующих e2e-теста # а не спецификация.
# (test_dedup_collapses_domklik_cian_name_order, assert estimator._parse_street_house("ул. Ленина, 10к1") == ("ленина", "10/1")
# test_dedup_collapses_domklik_cian_area_straddle), т.к. guard_house видел assert estimator._parse_street_house("ул. Ленина, 10 к1") == ("ленина", "10/1")
# «6к» != «6» и блокировал слияние. Обнаружено прогоном полного набора
# тестов при реализации — `(?!\w)` устраняет регрессию.
assert estimator._parse_street_house("ул. Ленина, 10к1") == ("ленина", "10")
def test_parse_street_house_ambiguous_glued_digit_runs_limitation() -> None: 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) out = estimator._dedup_cross_source(lots)
assert len(out) == 2 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к")