From 1a577fe7487de95619278d1211d969b5cdf25032 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 05:11:47 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein/matching):=20=D1=81=D0=BD=D1=8F?= =?UTF-8?q?=D1=82=D1=8C=20=D1=81=D0=BB=D0=B8=D1=8F=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=93=D0=90=D0=A0-GUID,=20=D0=BF=D0=BE=D1=87?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D1=82=D1=8C=20=D0=BF=D1=80=D0=B8=D1=91=D0=BC?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=20=D0=BA=D0=B0=D0=B4=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=20=D0=B8=20keeper=20(#2674)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью PR #2688 нашло, что расширение ключа дедупа было неверным. Снимаю его полностью и добавляю три правки, которых не хватало. СНЯТО: слияние 781 дома по COALESCE(house_fias_id, gar_house_guid). Аргумент «общий UUID здания есть независимая идентичность» оказался круговым. gar_flats_loader проставляет gar_house_guid предикатом WHERE tradein_canon_addr(COALESCE(h.short_address, h.full_address, h.address)) = gp.canon — левая часть побайтово равна ключу канон-прохода, то есть guid является детерминированной функцией канон-адреса, а не вторым наблюдением. Проход шёл с выключенным гео-стражем, значит #2187 обходился боковой дверью: канон-проход отказывается слить два дома в 6 км, а этот сливал их же за «общий UUID», выданный за тот же адрес. Плюс gar_pick берёт DISTINCT ON (canon) — одна ГАР-строка на канон, а 20.3% канонов накрывают несколько зданий, и ЕКБ-фильтр стоит только на стороне ГАР. Кедровка/Советская 17 уехала бы в ЕКБ. Нужен ключ, независимый от канона, либо включённый гео-страж — это другая задача. Приёмник кадастра сужен до кадастра ЗДАНИЯ. Параметр cadastral_number (кадастр КВАРТИРЫ) убран из match_or_create_house, Protocol HouseMatcher, RealMatcherAdapter и обоих вызывающих; `cad` больше не падает на него фолбэком. Мина была отложенной: начни Циан отдавать offer["cadastralNumber"], который парсер уже читает, — у каждой квартиры свой номер, Tier 0 не сматчил бы никогда, New-house INSERT записал бы номер квартиры в houses.cadastral_number и попутно снял P1-страж «безномерный адрес без кадастра не создаём». Две квартиры одного дома дали бы два дома — то самое дробление. В listings оба поля пишутся как раньше. Keeper: listing_cnt DESC NULLS LAST. Счётчик приходит из LEFT JOIN, у дома без объявлений он NULL, а DESC в Postgres — NULLS FIRST, поэтому пустая запись обгоняла запись со 192 объявлениями вопреки задокументированному правилу. Дефект предсуществующий и живой для канон-прохода. Сторож границы вызова для живого ФИАС-тира. Прежние проверки были структурными — видели имя параметра в сигнатуре. Уберут аргумент на настоящей границе (estimator.estimate_quality -> match_house_readonly) — сигнатура цела, тесты зелёные, тир снова мёртв. Новый тест смотрит на сам вызов. Заявление «тест ловит неуловимый класс» из прошлого описания снято как преувеличение: структурная проверка ловит подслучай, и building_cadastral_number её проходит при нуле срабатываний из 49 502. Остаётся из первого захода: снятый фильтр поиска has_kadastr, разделение ФИАС-тира (удалён в пути создания, оставлен в read-only), поправка ложного утверждения в шапке cadastral_geo_match.py. Refs #2674 --- .../backend/app/services/house_dedup_merge.py | 47 +++---- .../backend/app/services/matching/houses.py | 19 ++- .../backend/app/services/scraper_adapters.py | 2 - .../scripts/backfill_listing_sources.py | 2 +- .../backend/tests/test_house_dedup_merge.py | 118 ++++-------------- tradein-mvp/backend/tests/test_matching.py | 7 +- .../test_matching_tier_reachability_2674.py | 38 ++++++ .../scraper-kit/src/scraper_kit/base.py | 4 +- .../scraper-kit/src/scraper_kit/contracts.py | 5 +- 9 files changed, 108 insertions(+), 134 deletions(-) diff --git a/tradein-mvp/backend/app/services/house_dedup_merge.py b/tradein-mvp/backend/app/services/house_dedup_merge.py index c37f269f..330b66c0 100644 --- a/tradein-mvp/backend/app/services/house_dedup_merge.py +++ b/tradein-mvp/backend/app/services/house_dedup_merge.py @@ -35,13 +35,11 @@ WHAT this is: TWO PASSES (2026-07-02 follow-up): the SAME cluster→keeper→re-point→carry-identity→delete pipeline now runs TWICE inside one transaction, parametrised by the cluster-key expression (`_mapping_sql`) — no 300-line copy-paste: - 1. FIAS pass — clusters by lower(COALESCE(house_fias_id, gar_house_guid)) — the ФИАС/ГАР - building UUID, whichever source filled it (DaData estimate-путь / ГАР-лоадер; #2674 - расширил ключ со второго слагаемого, детали у _FIAS_KEY_EXPR). Only non-null UUIDs - shared by >1 house_id cluster. Catches duplicates the canon MISSES: slash-collapse - («Сулимова, 32» vs «Сулимова, 3/2» → same canon «сулимова32»), посёлок truncation - (Кедровка/Шувакиш colliding with a same-named ЕКБ street) и — основная масса — те, что - канон-проход УЗНАЁТ, но не сливает из-за гео-стража (битая/пустая координата). + 1. FIAS pass — clusters by lower(NULLIF(house_fias_id, '')) (the ФИАС/ГАР building UUID, + DaData-backfilled 2026-07-02 for 5 122 houses). Only non-null fias shared by >1 house_id + cluster. Catches duplicates the canon MISSES: slash-collapse («Сулимова, 32» vs + «Сулимова, 3/2» → same canon «сулимова32») and посёлок truncation (Кедровка/Шувакиш + colliding with a same-named ЕКБ street). 2. CANON pass — the canonical-address clustering above, now with a CROSS-FIAS GUARD: within a canon cluster a loser is NOT merged when it AND the keeper both carry a non-null but DIFFERENT house_fias_id — provably different buildings the canon collapsed (the @@ -134,9 +132,16 @@ _COMPLETENESS_EXPR = """ # Keeper ORDER BY, shared by the ROW_NUMBER() rank and the first_value() keeper pick so they # agree row-for-row. Priority: geom present → most linked listings → most-populated → min id. +# +# NULLS LAST на listing_cnt (#2674): счётчик приходит из LEFT JOIN listing_counts, поэтому у дома +# БЕЗ объявлений он NULL, а `DESC` в Postgres по умолчанию NULLS FIRST — то есть строка с нулём +# объявлений обгоняла строку со 192 и забирала роль keeper'а, ровно наоборот задокументированному +# правилу. Последствие не косметическое: объявления проигравшего переезжают на запись, на которую +# корпус никогда не ссылался, а COALESCE-перенос полей неполон (год постройки / тип дома / +# этажность / застройщик не переносятся) — данные богатого проигравшего удаляются безвозвратно. _KEEPER_ORDER = f""" (h.geom IS NOT NULL) DESC, - listing_cnt DESC, + listing_cnt DESC NULLS LAST, ({_COMPLETENESS_EXPR}) DESC, h.id ASC """ @@ -147,25 +152,10 @@ _KEEPER_ORDER = f""" # # FIAS key (#1772 follow-up, 2026-07-02): the ФИАС/ГАР building UUID (DaData backfill). Catches # duplicates the canon misses — slash-collapse («Сулимова, 32» vs «Сулимова, 3/2») and посёлок -# truncation (Кедровка/Шувакиш). Only non-null UUIDs cluster. -# -# #2674: ключ расширен с `house_fias_id` до COALESCE(house_fias_id, gar_house_guid). Это ОДИН И -# ТОТ ЖЕ UUID здания в ГАР — на проде 3 666 из 3 667 домов, где заполнены оба поля, несут -# идентичное значение (0.03% расхождения), и domrf_kapremont_loader уже читает их одним -# COALESCE. Разошлись они только по происхождению: house_fias_id заполняет DaData из -# estimate-пути (3 678 домов), gar_house_guid — ГАР-лоадер (5 551 дом), и вторая половина в -# ключ не входила. Замер на проде 2026-08-05: 653 кластера / 1 434 дома / 781 лишняя строка -# (8.3% таблицы houses) и 6 389 объявлений на них — один физический дом, размазанный по -# нескольким записям (риск #1772). Ни один существующий проход их не берёт: canon-проход видит -# у 900 из 919 пар ОДИН И ТОТ ЖЕ канон-адрес, но блокирует слияние гео-стражем — 356 пар имеют -# NULL geom с одной стороны, 457 разъехались дальше 250 м (максимум — 5 065 км, битый геокод). -# Это ровно аргумент #2187: общий UUID здания ЕСТЬ идентичность и старше близости, поэтому -# проход и идёт с apply_geo_guard=False (keeper'ом станет строка с geom → сломанная координата -# заодно чинится). +# truncation (Кедровка/Шувакиш). Only non-null fias clusters. _FIAS_KEY_EXPR = """ - CASE WHEN COALESCE(NULLIF(house_fias_id, ''), NULLIF(gar_house_guid, '')) IS NOT NULL - THEN 'fias:' - || lower(COALESCE(NULLIF(house_fias_id, ''), NULLIF(gar_house_guid, ''))) + CASE WHEN NULLIF(house_fias_id, '') IS NOT NULL + THEN 'fias:' || lower(house_fias_id) END """ @@ -187,9 +177,8 @@ def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str: """Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression. Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers - are rn>1. The CROSS-FIAS guard always applies. С расширенным ключом (#2674) он перестал быть - no-op'ом для fias-прохода: кластер, собранный по общему gar_house_guid, может содержать строки - с РАЗНЫМ непустым house_fias_id — такую пару guard блокирует (консервативно, 1 случай на проде). + are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered + row shares one fias by construction). apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True. - CANON pass → True: the canon strips город/район, so same-street-number buildings in diff --git a/tradein-mvp/backend/app/services/matching/houses.py b/tradein-mvp/backend/app/services/matching/houses.py index 26231e20..413b82ca 100644 --- a/tradein-mvp/backend/app/services/matching/houses.py +++ b/tradein-mvp/backend/app/services/matching/houses.py @@ -29,7 +29,12 @@ обратно в матчер не подаются. Подавать их сюда НЕЛЬЗЯ: как ключ здания KNN-кадастр не инъективен — 656 из 3 260 значений накрывают >1 ГАР-здание (20.1%), это был бы over-merge с confidence 1.0. Оставлен как рабочий приёмник на случай, если площадка - начнёт отдавать настоящий кадастр (парсер Циана уже пишет оба поля). + начнёт отдавать настоящий кадастр — но приёмник СУЖЕН до кадастра ЗДАНИЯ: параметр + `cadastral_number` (кадастр КВАРТИРЫ) убран из сигнатуры, Protocol и обоих вызывающих. + Он был отложенной миной: у каждой квартиры свой номер, Tier 0 не сматчил бы никогда, + падение в New-house INSERT записало бы номер квартиры в `houses.cadastral_number` и + попутно сняло P1-страж «безномерный адрес без кадастра не создаём» — по дому на + квартиру. В `listings` оба поля пишутся как раньше; из ключа дома ушло только ложное. Algorithm reference: decisions/Cross_Source_Matching_Strategy.md sec 3 """ @@ -70,7 +75,6 @@ def match_or_create_house( *, year_built: int | None = None, building_cadastral_number: str | None = None, - cadastral_number: str | None = None, source_url: str | None = None, ) -> tuple[int | None, float, str]: """Match existing house or create new canonical record. @@ -107,7 +111,16 @@ def match_or_create_house( 'р-н Чкаловский, мкр. Вторчермет' 480). A cadastral number is a precise building identity, so cad-carrying rows stay exempt (Tier 0 owns them). """ - cad = building_cadastral_number or cadastral_number + # ТОЛЬКО кадастр ЗДАНИЯ (#2674). Раньше было `building_cadastral_number or cadastral_number`, + # где второе — кадастр КВАРТИРЫ (у каждой свой), и параметр `cadastral_number` тоже убран из + # сигнатуры. Пока площадки не отдают ни того ни другого, фолбэк спал; но он и есть ловушка, + # ради которой мы «оставили рабочий приёмник»: начни Циан отдавать `offer["cadastralNumber"]` + # (парсер читает именно его), квартирный номер поехал бы в ключ ЗДАНИЯ. Tier 0 не сматчил бы + # никогда (у каждой квартиры свой номер) → падение в New-house INSERT → номер КВАРТИРЫ + # проштампован в houses.cadastral_number, плюс снят P1-страж ниже («безномерный адрес без + # кадастра не создаём» — `cad` там же и разрешает создание). Две квартиры одного дома дали бы + # два дома — то самое дробление, против которого Tier 0 и заведён. + cad = building_cadastral_number # Compute fingerprint early so we can acquire the advisory lock before any tier reads. fp = address_fingerprint(address, lat, lon) diff --git a/tradein-mvp/backend/app/services/scraper_adapters.py b/tradein-mvp/backend/app/services/scraper_adapters.py index 15d228f0..2192cd8c 100644 --- a/tradein-mvp/backend/app/services/scraper_adapters.py +++ b/tradein-mvp/backend/app/services/scraper_adapters.py @@ -66,7 +66,6 @@ class RealMatcherAdapter: *, year_built: int | None = None, building_cadastral_number: str | None = None, - cadastral_number: str | None = None, source_url: str | None = None, ) -> tuple[int | None, float, str]: # house_id is None when the matcher refuses a numberless address without a @@ -80,7 +79,6 @@ class RealMatcherAdapter: lon, year_built=year_built, building_cadastral_number=building_cadastral_number, - cadastral_number=cadastral_number, source_url=source_url, ) diff --git a/tradein-mvp/backend/scripts/backfill_listing_sources.py b/tradein-mvp/backend/scripts/backfill_listing_sources.py index ad0b4283..e3e8c8ba 100644 --- a/tradein-mvp/backend/scripts/backfill_listing_sources.py +++ b/tradein-mvp/backend/scripts/backfill_listing_sources.py @@ -291,8 +291,8 @@ def _link_listing_to_house( lat=row.lat, lon=row.lon, year_built=row.year_built, + # Кадастр КВАРТИРЫ (row.cadastral_number) в ключ дома не идёт (#2674). building_cadastral_number=row.building_cadastral_number, - cadastral_number=row.cadastral_number, source_url=row.house_url or row.source_url, ) house_resolved = house_id is not None diff --git a/tradein-mvp/backend/tests/test_house_dedup_merge.py b/tradein-mvp/backend/tests/test_house_dedup_merge.py index 4271c0d3..ef6494e0 100644 --- a/tradein-mvp/backend/tests/test_house_dedup_merge.py +++ b/tradein-mvp/backend/tests/test_house_dedup_merge.py @@ -106,6 +106,24 @@ def test_keeper_rule_priority_order() -> None: assert col in hdm._COMPLETENESS_EXPR +def test_keeper_listing_count_puts_nulls_last() -> None: + """«Больше объявлений» обязано означать больше, а не меньше (#2674). + + listing_cnt приходит из LEFT JOIN listing_counts → у дома БЕЗ объявлений он NULL, а `DESC` + в Postgres по умолчанию NULLS FIRST. Без явного NULLS LAST правило инвертировано: пустая + запись обгоняет запись со 192 объявлениями и забирает роль keeper'а, после чего объявления + проигравшего переезжают на дом, на который корпус никогда не ссылался, а неполный + COALESCE-перенос (год постройки / тип / этажность / застройщик не переносятся) удаляет + данные более богатой строки. RED до фикса: был голый `listing_cnt DESC`. + """ + order = _flat(hdm._KEEPER_ORDER) + assert "listing_cnt DESC NULLS LAST" in order + # Оба места, где применяется порядок (ROW_NUMBER-ранг и first_value-выбор keeper'а), + # берут одну и ту же константу — иначе ранг и keeper разъедутся построчно. + mapping = _flat(hdm._mapping_sql(hdm._FIAS_KEY_EXPR, apply_geo_guard=False)) + assert mapping.count("listing_cnt DESC NULLS LAST") >= 2 + + def test_mapping_selects_losers_only() -> None: """The mapping is loser_id→keeper_id; the keeper itself is excluded (rn>1, id<>keeper).""" flat = _flat(_MAPPING_SQL) @@ -240,27 +258,18 @@ def test_no_fstring_or_format_in_parametrised_sql() -> None: def test_fias_pass_clusters_by_house_fias_id() -> None: - """A second cluster key: the ФИАС/ГАР building UUID from EITHER column (#2674). + """A second cluster key: lower(NULLIF(house_fias_id,'')) — the ФИАС/ГАР building UUID. The fias pass catches duplicates the address canon misses (slash-collapse «Сулимова, 32» vs - «Сулимова, 3/2»; посёлок truncation). Only non-null UUIDs shared by >1 house_id cluster. - - #2674: ключ — COALESCE(house_fias_id, gar_house_guid). Это один и тот же UUID здания - (на проде 3 666/3 667 совпадений там, где заполнены оба), но заполняют его разные - источники: DaData из estimate-пути и ГАР-лоадер. Пока ключом был только house_fias_id, - 5 551 дом с gar_house_guid в проход не попадал — 781 дублирующая строка (8.3% houses) - и 6 389 объявлений на них оставались раздробленными (риск #1772). + «Сулимова, 3/2»; посёлок truncation). Only non-null fias shared by >1 house_id clusters. """ flat = _flat(_FIAS_MAPPING_SQL) - coalesce = "COALESCE(NULLIF(house_fias_id, ''), NULLIF(gar_house_guid, ''))" - assert f"'fias:' || lower({coalesce})" in flat - assert f"{coalesce} IS NOT NULL" in flat - # ГАР-GUID обязан быть частью ключа — иначе половина заполненных идентичностей вне прохода. - assert "gar_house_guid" in flat + assert "'fias:' || lower(house_fias_id)" in flat + assert "NULLIF(house_fias_id, '') IS NOT NULL" in flat # same collision-safe pipeline (same temp table + >1 grouping), NOT the canon cluster key. assert "CREATE TEMP TABLE _1772_dup_mapping" in flat assert "HAVING count(*) > 1" in flat - # the fias pass keys on the building UUID, never on the 'addr:' canon key. + # the fias pass keys on the fias UUID, never on the 'addr:' canon key. assert "'addr:'" not in flat @@ -1022,84 +1031,3 @@ def test_real_fias_pass_ignores_geo_guard() -> None: db.execute(_t("DELETE FROM houses WHERE id BETWEEN 900030 AND 900035")) db.commit() db.close() - - -@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") -def test_real_identity_pass_clusters_on_gar_house_guid() -> None: - """End-to-end на живой БД для #2674 — идентичность берётся из ОБЕИХ UUID-колонок: - - A. одинаковый gar_house_guid, house_fias_id пуст у обоих, разные каноны и >250 м - → СЛИТЫ (раньше проход их не видел: ключом был только house_fias_id, а гео-страж - канон-прохода такую пару блокирует). - B. gar_house_guid у одного, тот же UUID в house_fias_id у другого (ровно смешанный - случай прода: DaData заполнила одну строку, ГАР-лоадер — другую) → СЛИТЫ. - C. РАЗНЫЕ gar_house_guid, одинаковый канон, >250 м → НЕ слиты (канон-страж на месте, - расширение ключа не расширило слияние). - - Улицы «*2674» синтетические, с прод-адресами/алиасами не пересекаются. - Дельта широты 0.045° на ~56.84° ≈ 5 км (>250 м). - """ - from sqlalchemy import text as _t - - db = _live_session() - assert db is not None - try: - db.execute( - _t( - "INSERT INTO houses " - "(id, source, ext_house_id, address, lat, lon, house_fias_id, gar_house_guid) " - "VALUES " - # A — общий ГАР-GUID, fias пуст, разные каноны, ~5 км → слияние по идентичности - "(900040,'avito','EXT-2674-A-K','ГарОдин2674, 1', 56.84000,60.60000," - " NULL,'G-A-2674')," - "(900041,'cian', 'EXT-2674-A-L','ГарДругой2674, 2',56.88500,60.60000," - " NULL,'G-A-2674')," - # B — один и тот же UUID, но в РАЗНЫХ колонках → всё равно один кластер - "(900042,'avito','EXT-2674-B-K','ГарМикс2674, 3', 56.84000,60.60000," - " NULL,'G-B-2674')," - "(900043,'cian', 'EXT-2674-B-L','ГарМиксДва2674, 4',56.88500,60.60000," - " 'G-B-2674',NULL)," - # C — РАЗНЫЕ ГАР-GUID, общий канон, ~5 км → канон-страж по-прежнему блокирует - "(900044,'avito','EXT-2674-C-1','ГарКанон2674, 5', 56.84000,60.60000," - " NULL,'G-C1-2674')," - "(900045,'cian', 'EXT-2674-C-2','ГарКанон2674, 5', 56.88500,60.60000," - " NULL,'G-C2-2674')" - ) - ) - # Объявление на проигравшем A — доказываем, что переуказание отработало. - db.execute( - _t( - "INSERT INTO listings " - "(id, source, source_url, source_id, dedup_hash, price_rub, house_id_fk) VALUES " - "(910041,'cian','http://t/2674/al','L-2674-AL','dh-2674-al',6000000,900041)" - ) - ) - db.commit() - - out = hdm.merge_duplicate_houses(db, dry_run=False) - assert out["losers_deleted"] >= 2 # A + B - - ids = [ - r.id - for r in db.execute( - _t("SELECT id FROM houses WHERE id BETWEEN 900040 AND 900045 ORDER BY id") - ).all() - ] - # A: общий ГАР-GUID при пустом fias — раньше НЕ сливалось (ключ не видел колонку). - assert 900040 in ids and 900041 not in ids, "общий gar_house_guid обязан склеивать дома" - # B: один UUID в разных колонках — COALESCE обязан свести их в один кластер. - assert 900042 in ids and 900043 not in ids, "fias и gar с одним UUID — один кластер" - # C: разные UUID + общий канон + >250 м — слияния быть не должно. - assert 900044 in ids and 900045 in ids, "разные ГАР-GUID не должны сливаться" - - # A: объявление проигравшего переуказано на выжившего. - repointed = db.execute(_t("SELECT house_id_fk FROM listings WHERE id = 910041")).scalar() - assert repointed == 900040 - finally: - db.rollback() - db.execute(_t("DELETE FROM listings WHERE id = 910041")) - db.execute(_t("DELETE FROM house_sources WHERE house_id BETWEEN 900040 AND 900045")) - db.execute(_t("DELETE FROM house_address_aliases WHERE house_id BETWEEN 900040 AND 900045")) - db.execute(_t("DELETE FROM houses WHERE id BETWEEN 900040 AND 900045")) - db.commit() - db.close() diff --git a/tradein-mvp/backend/tests/test_matching.py b/tradein-mvp/backend/tests/test_matching.py index 458d05d3..f0a0c371 100644 --- a/tradein-mvp/backend/tests/test_matching.py +++ b/tradein-mvp/backend/tests/test_matching.py @@ -276,7 +276,8 @@ def test_match_house_tier0_cadastr(): address="ул Ленина 5", lat=56.8, lon=60.5, - cadastral_number="66:41:0000000:999", + # Кадастр ЗДАНИЯ — единственный, что принимает ключ дома (#2674). + building_cadastral_number="66:41:0000000:999", ) assert house_id == 42 assert conf == 1.0 @@ -605,7 +606,9 @@ def test_numberless_address_with_cadastral_creates_house(): "cian", "ext-cad", address="екатеринбург улица мамина сибиряка", - cadastral_number="66:41:0000000:12345", + # Именно кадастр ЗДАНИЯ снимает P1-страж: он идентифицирует дом. Кадастр КВАРТИРЫ этого + # права лишён (#2674) — иначе номер квартиры открывал бы создание мега-бакетов. + building_cadastral_number="66:41:0000000:12345", ) assert (house_id, conf, method) == (321, 1.0, "new") assert any( diff --git a/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py b/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py index 1da839b1..80bca572 100644 --- a/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py +++ b/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py @@ -69,3 +69,41 @@ def test_fias_tier_is_gone_from_create_path_but_alive_in_readonly() -> None: match_or_create_house ), "мёртвая fias-ветка вернулась в путь создания домов" assert '"fias_exact"' in inspect.getsource(match_house_readonly) + + +def test_readonly_fias_tier_is_actually_fed_by_its_caller() -> None: + """Единственный живой ФИАС-тир должен ПОЛУЧАТЬ аргумент, а не только объявлять параметр. + + Проверки выше — структурные: они видят имя в сигнатуре. Этого мало. Уберут аргумент на + настоящей границе вызова (`estimator.estimate_quality` → `match_house_readonly`) — сигнатура + останется прежней, оба теста выше зелёные, а тир снова мёртв, и мы снова здесь. + + Доказательство, что структурной проверки недостаточно, лежит рядом: `building_cadastral_number` + её проходит и при этом сработал 0 раз из 49 502. + """ + from app.services.estimator import estimate_quality + + src = inspect.getsource(estimate_quality) + assert "match_house_readonly" in src, "estimate_quality больше не зовёт read-only матчер" + assert "house_fias_id=" in src, ( + "estimate_quality перестал передавать house_fias_id — единственный живой fias-тир " + "снова недостижим (это и есть класс ошибки #2674)" + ) + + +def test_house_key_never_accepts_flat_cadastre() -> None: + """Ключ ДОМА строится только на кадастре здания — не на кадастре квартиры (#2674). + + Ловушка «оставленного рабочего приёмника»: у квартиры свой кадастр, поэтому Tier 0 по нему + не сматчит НИКОГДА, упадёт в New-house INSERT и проштампует номер квартиры в + houses.cadastral_number; плюс `cad` снимает P1-страж «безномерный адрес без кадастра не + создаём». Две квартиры одного дома → два дома. RED до фикса: параметр был в сигнатуре, а + `cad = building_cadastral_number or cadastral_number`. + """ + assert "cadastral_number" not in _params(match_or_create_house) + assert "building_cadastral_number" in _params(match_or_create_house) + + src = inspect.getsource(match_or_create_house) + assert ( + "cad = building_cadastral_number\n" in src + ), "в ключ дома вернулся фолбэк на кадастр квартиры" diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py index 82eeba66..4c4c4d36 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py @@ -908,8 +908,10 @@ def _link_listing_to_house( lat=lot.lat, lon=lot.lon, year_built=lot.year_built, + # Кадастр КВАРТИРЫ (lot.cadastral_number / lot.kadastr_num) сюда БОЛЬШЕ НЕ идёт + # (#2674) — ключ дома строится только на кадастре здания. В listings оба поля + # по-прежнему пишутся save_listings'ом, теряется только ложная идентичность. building_cadastral_number=lot.building_cadastral_number, - cadastral_number=lot.cadastral_number or lot.kadastr_num, source_url=lot.house_url or lot.source_url, ) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py index 56324055..9a9c822b 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py @@ -65,11 +65,14 @@ class HouseMatcher(Protocol): *, year_built: int | None = ..., building_cadastral_number: str | None = ..., - cadastral_number: str | None = ..., source_url: str | None = ..., ) -> tuple[int | None, float, str]: """Найти или создать канонический дом. + NB (#2674): `cadastral_number` (кадастр КВАРТИРЫ) из контракта УБРАН. Ключ дома — + только `building_cadastral_number`; квартирный номер в нём породил бы по дому на + квартиру, см. комментарий у `cad` в matching/houses.py. + Returns: (house_id, confidence ∈ [0.0, 1.0], method), где method ∈ { 'cadastr_exact', 'source_exact', 'fingerprint', 'geo_proximity', 'new',