diff --git a/tradein-mvp/backend/app/schemas/search.py b/tradein-mvp/backend/app/schemas/search.py index 62d9e5f2..d6cd6ae7 100644 --- a/tradein-mvp/backend/app/schemas/search.py +++ b/tradein-mvp/backend/app/schemas/search.py @@ -40,7 +40,9 @@ class SearchParams(BaseModel): floors_total_max: int | None = Field(default=None, ge=1) # --- Quality / cross-source --- - has_kadastr: bool = False + # has_kadastr снят (#2674): listings.cadastral_number пуст у всех 93 408 строк, + # фильтр мог вернуть только пустую выдачу. Лишний query-param FastAPI игнорирует, + # так что старые клиенты не ломаются. sources: list[Literal["avito", "cian", "yandex_realty"]] | None = None multi_source_only: bool = False require_avito: bool = False diff --git a/tradein-mvp/backend/app/services/house_dedup_merge.py b/tradein-mvp/backend/app/services/house_dedup_merge.py index ede3356b..330b66c0 100644 --- a/tradein-mvp/backend/app/services/house_dedup_merge.py +++ b/tradein-mvp/backend/app/services/house_dedup_merge.py @@ -132,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 """ diff --git a/tradein-mvp/backend/app/services/matching/houses.py b/tradein-mvp/backend/app/services/matching/houses.py index 2b130d29..413b82ca 100644 --- a/tradein-mvp/backend/app/services/matching/houses.py +++ b/tradein-mvp/backend/app/services/matching/houses.py @@ -1,11 +1,40 @@ """House cross-source matching — tiered algorithm. -Tier 0 (confidence 1.0): cadastral_number exact match on houses table. -Tier 0.5 (confidence 0.95): house_fias_id (ГАР OBJECTGUID) exact match, case-insensitive. -Tier 1 (confidence 1.0): ext_source + ext_id already in house_sources. -Tier 2 (confidence 0.9): address_fingerprint match in house_address_aliases. -Tier 3 (confidence 0.7): geo-proximity within 30 m (PostGIS ST_DWithin). -New (confidence 1.0): INSERT new canonical house. +`match_or_create_house` (путь скрейпинга, создаёт дома): + Tier 0 (confidence 1.0): cadastral_number exact match on houses table. + Tier 1 (confidence 1.0): ext_source + ext_id already in house_sources. + Tier 2 (confidence 0.9): address_fingerprint match in house_address_aliases. + Tier 3 (confidence 0.7): geo-proximity within 30 m (PostGIS ST_DWithin). + New (confidence 1.0): INSERT new canonical house. + +`match_house_readonly` (путь estimate-таргета, ничего не создаёт) дополнительно +имеет Tier 0.5 fias_exact — у него ЕСТЬ источник ФИАС (DaData /suggest в +`estimator.resolve_target_house`), см. docstring функции. + +ЧЕСТНОСТЬ ТИРОВ (#2674, замер на проде 2026-08-05, 49 502 строки house_sources): + fingerprint 58.97% · new 22.65% · geo_proximity 18.36% · + **cadastr_exact 0 · fias_exact 0** — верхние тиры не срабатывали НИ РАЗУ. + + • Tier 0.5 fias_exact из `match_or_create_house` УДАЛЁН: параметра `house_fias_id` + нет ни в Protocol `scraper_kit.contracts.HouseMatcher`, ни в + `app.services.scraper_adapters.RealMatcherAdapter`, ни у двух прямых вызывающих + (`estimator._save_yandex_history_items`, `scripts/backfill_listing_sources.py`) — + передать его было НЕКОМУ. Регресс сторожит + tests/test_matching_tier_reachability_2674.py. + • Tier 0 cadastr_exact ОСТАВЛЕН: он достижим по построению (`ScrapedLot. + building_cadastral_number` → адаптер → сюда), но площадки кадастр не отдают: + `listings.cadastral_number` 0/93 408, а все 28 504 заполненных + `listings.building_cadastral_number` — на 100% из локального гео-зеркала ЕГРН + (`tasks/cadastral_geo_match.py`, KNN ≤50 м), т.е. появляются ПОСЛЕ матчинга и + обратно в матчер не подаются. Подавать их сюда НЕЛЬЗЯ: как ключ здания 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 """ @@ -46,8 +75,6 @@ def match_or_create_house( *, year_built: int | None = None, building_cadastral_number: str | None = None, - cadastral_number: str | None = None, - house_fias_id: str | None = None, source_url: str | None = None, ) -> tuple[int | None, float, str]: """Match existing house or create new canonical record. @@ -58,21 +85,18 @@ def match_or_create_house( for an unknown address could both miss Tier 0-3 and each INSERT a duplicate house row. Closes finding #1 from 2026-05-24 audit. - Args: - house_fias_id: ГАР OBJECTGUID (UUID) of the building, when known upstream - (e.g. DaData /clean/address). Enables Tier 0.5 fias_exact — additive and - optional, existing callers are unaffected. + NB: параметра `house_fias_id` здесь НЕТ намеренно (#2674) — см. шапку модуля. + ФИАС-тир живёт только в `match_house_readonly`, у которого есть источник ФИАС. Returns: (house_id, confidence ∈ [0.0, 1.0], method ∈ { - 'cadastr_exact', 'fias_exact', 'source_exact', 'fingerprint', + 'cadastr_exact', 'source_exact', 'fingerprint', 'geo_proximity', 'new', 'no_house_number' }) house_id is None only for the 'no_house_number' terminal case below. Method values: 'cadastr_exact' — matched by cadastral number (confidence 1.0) - 'fias_exact' — matched by house_fias_id (ГАР OBJECTGUID) (confidence 0.95) 'source_exact' — already in house_sources for this source+ext_id (confidence 1.0) 'fingerprint' — matched by address fingerprint (confidence 0.9) 'geo_proximity' — matched by geo within 30 m (confidence 0.7) @@ -87,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) @@ -136,34 +169,10 @@ def match_or_create_house( logger.info("house match cadastr_exact house_id=%s cad=%s", house_id, cad) return (house_id, 1.0, "cadastr_exact") - # Tier 0.5: house_fias_id (ГАР OBJECTGUID) exact match, case-insensitive. - # Stable ORDER BY id so concurrent/duplicate rows resolve deterministically. - if house_fias_id: - row = ( - db.execute( - text( - "SELECT id FROM houses " - "WHERE lower(house_fias_id) = lower(CAST(:fias AS text)) " - "ORDER BY id ASC LIMIT 1" - ), - {"fias": house_fias_id}, - ) - .mappings() - .first() - ) - if row: - house_id = int(row["id"]) - _upsert_house_source( - db, - house_id=house_id, - ext_source=ext_source, - ext_id=ext_id, - method="fias_exact", - confidence=0.95, - ) - _insert_alias(db, house_id=house_id, address=address, fp=fp, source=ext_source) - logger.info("house match fias_exact house_id=%s fias=%s", house_id, house_fias_id) - return (house_id, 0.95, "fias_exact") + # Tier 0.5 fias_exact удалён (#2674): передать `house_fias_id` в этот путь было + # некому — ни Protocol HouseMatcher, ни RealMatcherAdapter, ни оба прямых вызывающих + # такого параметра не имели, поэтому за всю историю тир не сработал ни разу (0 из + # 49 502 house_sources). Живой ФИАС-тир остался в match_house_readonly. # Tier 1: source+ext_id already registered in house_sources row = ( 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/app/services/search_query.py b/tradein-mvp/backend/app/services/search_query.py index b7d06ed7..55890449 100644 --- a/tradein-mvp/backend/app/services/search_query.py +++ b/tradein-mvp/backend/app/services/search_query.py @@ -95,8 +95,12 @@ def build_search_query(params: SearchParams) -> tuple[str, dict[str, object]]: where.append("total_floors <= CAST(:fl_total_max AS integer)") args["fl_total_max"] = params.floors_total_max - if params.has_kadastr: - where.append("cadastral_number IS NOT NULL") + # Фильтр has_kadastr удалён (#2674): `listings.cadastral_number` (кадастр КВАРТИРЫ) + # пуст у всех 93 408 объявлений — площадки его не отдают (единственный писатель, + # парсер Циана, читает offer["cadastralNumber"], которого в ответе нет). Предикат + # `cadastral_number IS NOT NULL` мог вернуть только пустую выдачу, т.е. обещал + # качество данных, которого нет. Колонка и её писатель оставлены: если площадка + # начнёт отдавать кадастр, заполнение заработает само — тогда и вернём фильтр. segment_clause = _SEGMENT_SQL[params.segment] if segment_clause is not None: diff --git a/tradein-mvp/backend/app/tasks/cadastral_geo_match.py b/tradein-mvp/backend/app/tasks/cadastral_geo_match.py index 3bb58c55..adc21d50 100644 --- a/tradein-mvp/backend/app/tasks/cadastral_geo_match.py +++ b/tradein-mvp/backend/app/tasks/cadastral_geo_match.py @@ -15,9 +15,18 @@ APPROXIMATION (deliberate first increment): This is a GEO-NEAREST match — a street-level-geocoded listing is matched to the nearest cadastral building within `threshold_m`, NOT necessarily its exact cadastral building. The threshold is always logged. Exact cadastral resolution + parcel-containment are - deferred (cad_parcels FDW not exposed). Tier-0 house matching in the estimator already - treats building_cadastral_number as a hint, not ground truth, so an approximate fill is - a net win over 0% coverage. + deferred (cad_parcels FDW not exposed). + + ЭТО HINT, И ТОЛЬКО HINT (#2674 — правка прежнего утверждения в этой шапке). + Раньше здесь было написано, что Tier-0 матчинга домов «уже трактует + building_cadastral_number как подсказку»; это неверно — Tier 0 в + `app/services/matching/houses.py` отдаёт confidence 1.0, т.е. точное совпадение. + Замер на проде 2026-08-05: 656 из 3 260 заполненных здесь значений накрывают более + одного здания ГАР (20.1%), а 751 из 2 864 зданий ГАР получают более одного значения + (26.2%) — ключ не инъективен ни в одну сторону. Поэтому подавать эту колонку в Tier 0 + (ни на пере-скрейпе, ни бэкфиллом в houses.cadastral_number) НЕЛЬЗЯ: это склеит разные + здания с максимальной уверенностью. Колонка годится как признак/подсказка, не как + идентичность здания. Pipeline (one combined run, scheduler source='cadastral_geo_match'): 1. refresh_cad_buildings_local(db) — TRUNCATE + bulk INSERT from FDW (one scan). 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 c6bacef4..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) diff --git a/tradein-mvp/backend/tests/test_matching.py b/tradein-mvp/backend/tests/test_matching.py index 622e2732..f0a0c371 100644 --- a/tradein-mvp/backend/tests/test_matching.py +++ b/tradein-mvp/backend/tests/test_matching.py @@ -276,64 +276,21 @@ 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 assert method == "cadastr_exact" -def test_match_house_tier05_fias_exact(): - """Tier 0.5: house_fias_id match → fias_exact (conf 0.95) before source/fp/geo. - - No cadastral_number supplied → Tier 0 skipped; the fias SELECT is the first - lookup after the advisory lock. On hit, _upsert_house_source + _insert_alias - fire (address carries a house number so the alias is registered). - """ - from app.services.matching.houses import match_or_create_house - - db = _make_db( - [ - None, # pg_advisory_xact_lock - {"id": 55}, # fias_exact hit (Tier 0.5) - None, # _upsert_house_source - None, # _insert_alias - ] - ) - house_id, conf, method = match_or_create_house( - db, - "avito", - "ext-fias-1", - address="ул Ленина 5", - lat=56.8, - lon=60.5, - house_fias_id="0a1b2c3d-0000-4000-8000-000000000001", - ) - assert house_id == 55 - assert conf == 0.95 - assert method == "fias_exact" - - -def test_match_house_fias_skipped_when_absent(): - """No house_fias_id → Tier 0.5 does not run; first lookup is house_sources.""" - from app.services.matching.houses import match_or_create_house - - db = _make_db( - [ - None, # pg_advisory_xact_lock - {"house_id": 8}, # house_sources hit (fias tier skipped, no fias SELECT) - ] - ) - house_id, _conf, method = match_or_create_house( - db, - "avito", - "ext-nofias", - address="пр Мира 10", - lat=56.8, - lon=60.5, - ) - assert house_id == 8 - assert method == "source_exact" +# Тесты Tier 0.5 fias_exact для match_or_create_house удалены вместе с самим тиром (#2674): +# передать туда `house_fias_id` не мог ни один вызывающий (ни Protocol HouseMatcher, ни +# RealMatcherAdapter такого параметра не имели), поэтому за всю историю тир не сработал ни разу — +# 0 из 49 502 строк house_sources. Тесты при этом были зелёными: они звали функцию напрямую и +# передавали параметр сами, минуя границу вызова. Ровно этот класс ошибок теперь сторожит +# tests/test_matching_tier_reachability_2674.py (сверка сигнатуры с границей вызова). +# Живой fias-тир остался в match_house_readonly и покрыт тестами estimate-пути. def test_match_house_tier1_source_exact(): @@ -649,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 new file mode 100644 index 00000000..80bca572 --- /dev/null +++ b/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py @@ -0,0 +1,109 @@ +"""Достижимость тиров матчинга домов (#2674). + +Эпик #2674 — «код написан, отревьюен, смержен и ни разу не сработал». Два верхних тира +`match_or_create_house` были именно такими: за всю историю 0 из 49 502 строк house_sources +имеют matched_method 'cadastr_exact' или 'fias_exact'. + +Причина у fias-тира структурная: параметр `house_fias_id` существовал только в сигнатуре +самой функции — ни Protocol `scraper_kit.contracts.HouseMatcher`, ни продуктовый адаптер +`RealMatcherAdapter`, ни прямые вызывающие его не имели, т.е. передать значение было НЕКОМУ. +Обычный тест этого не ловит: он вызывает функцию напрямую и передаёт параметр сам. + +Ловит — сверка сигнатуры с границей вызова. Здесь она и живёт: каждый keyword-параметр +матчера обязан быть выразим у вызывающего, иначе ветка недостижима в проде. +""" + +from __future__ import annotations + +import inspect +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.contracts import HouseMatcher + +from app.services.matching.houses import match_house_readonly, match_or_create_house +from app.services.scraper_adapters import RealMatcherAdapter + + +def _params(func: object) -> set[str]: + return set(inspect.signature(func).parameters) - {"self", "db"} + + +def test_create_path_matcher_params_are_all_reachable_from_the_boundary() -> None: + """Каждый параметр `match_or_create_house` должен быть передаваем через границу. + + Границы две, и обе обязаны выражать полный набор: + * Protocol `HouseMatcher` — контракт, который видит scraper-kit; + * `RealMatcherAdapter` — единственная боевая реализация этого контракта. + + Параметр, которого нет ни там ни там, — недостижимая ветка (случай `house_fias_id`, + #2674). RED до фикса: {'house_fias_id'}. + """ + impl = _params(match_or_create_house) + unreachable_via_protocol = impl - _params(HouseMatcher.match_or_create_house) + unreachable_via_adapter = impl - _params(RealMatcherAdapter.match_or_create_house) + + assert not unreachable_via_protocol, ( + f"параметры матчера, недостижимые через Protocol HouseMatcher: " + f"{sorted(unreachable_via_protocol)} — ветка не сработает в проде" + ) + assert not unreachable_via_adapter, ( + f"параметры матчера, недостижимые через RealMatcherAdapter: " + f"{sorted(unreachable_via_adapter)} — ветка не сработает в проде" + ) + + +def test_fias_tier_is_gone_from_create_path_but_alive_in_readonly() -> None: + """Fias-тир удалён там, где источника ФИАС нет, и сохранён там, где он есть. + + `match_house_readonly` зовёт estimator.resolve_target_house и передаёт ФИАС из + payload.target_fias_id / DaData — источник реальный, тир оставляем. + """ + assert "house_fias_id" not in _params(match_or_create_house) + assert "house_fias_id" in _params(match_house_readonly) + + # Ищем именно литерал method-значения ('"fias_exact"'), а не слово в комментарии: + # комментарий-надгробие про удалённый тир остаться должен, ветка — нет. + assert '"fias_exact"' not in inspect.getsource( + 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/backend/tests/test_search_api.py b/tradein-mvp/backend/tests/test_search_api.py index 0a7fc383..5ba489bd 100644 --- a/tradein-mvp/backend/tests/test_search_api.py +++ b/tradein-mvp/backend/tests/test_search_api.py @@ -39,7 +39,6 @@ def test_build_query_all_filters(): area_m2_max=80, price_rub_max=10_000_000, year_built_min=2000, - has_kadastr=True, multi_source_only=True, require_avito=True, address_query="Малышева", @@ -49,13 +48,28 @@ def test_build_query_all_filters(): assert "total_area >= CAST(:area_min AS double precision)" in sql assert "price_rub <= CAST(:price_max AS bigint)" in sql assert "year_built >= CAST(:yb_min AS integer)" in sql - assert "cadastral_number IS NOT NULL" in sql assert "source_count >= 2" in sql assert "has_avito = true" in sql assert "address ILIKE" in sql assert args["addr_like"] == "%Малышева%" +def test_no_unsatisfiable_cadastral_filter() -> None: + """#2674: фильтр по кадастру снят — он мог вернуть только пустую выдачу. + + `listings.cadastral_number` (кадастр КВАРТИРЫ) пуст у всех 93 408 объявлений на проде: + площадки его не отдают. Предикат `cadastral_number IS NOT NULL` в WHERE обещал + пользователю фильтр качества данных, которого не существует. Ни один набор параметров + поиска не должен уметь его собрать. RED до фикса: has_kadastr=True вставлял предикат. + """ + for params in ( + SearchParams(), + SearchParams(**{"has_kadastr": True}), # старый клиент — параметр игнорируется + ): + sql, _ = build_search_query(params) + assert "cadastral_number IS NOT NULL" not in sql + + def test_build_query_sources_array(): sql, args = build_search_query(SearchParams(sources=["avito", "cian"])) assert "sources && CAST(:sources AS text[])" in sql 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',