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
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:
parent
752c3656a3
commit
deffd31156
2 changed files with 257 additions and 38 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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к")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue