From deffd311561ba8ddad04710c766fb5d5a106c6cd Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 4 Jul 2026 19:50:52 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/estimator):=20=D0=B2=D0=BE=D1=81?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=20=D0=B8?= =?UTF-8?q?=20=D0=BA=D0=B0=D0=BD=D0=BE=D0=BD=D0=B8=D0=B7=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BA=D0=BE=D1=80=D0=BF=D1=83=D1=81?= =?UTF-8?q?=20=D0=B4=D0=BE=D0=BC=D0=B0=20=D0=B2=20=D0=B4=D0=B5=D0=B4=D1=83?= =?UTF-8?q?=D0=BF=D0=B5=20(#2436)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Регрессия 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 этого фикса, не блокирует. --- tradein-mvp/backend/app/services/estimator.py | 65 ++++- .../test_estimator_dedup_cross_source_2087.py | 230 +++++++++++++++--- 2 files changed, 257 insertions(+), 38 deletions(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index f222c1fa..827a4b37 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py b/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py index c5be831b..4492cbad 100644 --- a/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py +++ b/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py @@ -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к")