fix(tradein/matching): честность тиров сопоставления домов (#2674)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
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 2m54s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
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 2m54s
Три находки эпика #2674 про верхние тиры матчинга домов. Замеры — прод tradein-postgres, 2026-08-05/06. Кадастр от площадок не приходит вообще. listings.cadastral_number (кадастр КВАРТИРЫ) — 0 из 93 408; единственный писатель, парсер Циана, читает offer["cadastralNumber"], которого в ответе нет. Все 28 504 заполненных building_cadastral_number на 100% пришли из локального гео-зеркала ЕГРН (tasks/cadastral_geo_match.py, KNN <=50 м) — проверено джойном к cad_buildings_local. Поэтому снят фильтр поиска has_kadastr: предикат `cadastral_number IS NOT NULL` мог вернуть только пустую выдачу. Колонка и писатель оставлены — заработают сами, если площадка начнёт отдавать кадастр. Tier 0 cadastr_exact оставлен, но не подключён к гео-кадастру. Он достижим по построению (ScrapedLot -> адаптер -> матчер), просто данных нет; подать туда KNN-заполнение НЕЛЬЗЯ: как ключ здания оно не инъективно — 656 из 3 260 значений накрывают >1 здание ГАР (20.1%), 751 из 2 864 зданий получают >1 значение (26.2%). Это был бы over-merge с confidence 1.0. Заодно исправлено ложное утверждение в шапке cadastral_geo_match.py, будто Tier 0 трактует эту колонку как подсказку. Tier 0.5 fias_exact удалён из match_or_create_house. Параметра house_fias_id не было ни в Protocol scraper_kit.contracts.HouseMatcher, ни в RealMatcherAdapter, ни у двух прямых вызывающих — передать его было некому. В match_house_readonly тир оставлен: у estimate-пути источник ФИАС есть (payload.target_fias_id / DaData). Что чинит сопоставление на самом деле: ключ идентичности в house_dedup_merge расширен с house_fias_id до COALESCE(house_fias_id, gar_house_guid). Это один и тот же UUID здания в ГАР (3 666 совпадений из 3 667 домов, где заполнены оба), но заполняют его разные источники, и половина в проход не входила. Read-only прогон отрендеренного mapping-SQL на проде: старый ключ — 0 пар, новый — 781 (8.3% таблицы houses, 6 389 объявлений на них). Канон-проход эти дома узнаёт (900 пар из 919 имеют один канон-адрес), но блокирует гео-стражем: 356 пар с NULL geom, 457 дальше 250 м (максимум 5 065 км — битый геокод). Ровно аргумент #2187: общий UUID здания старше близости. Качество сопоставления сейчас: 0 из 49 502 строк house_sources сматчены верхними тирами; fingerprint 58.97%, new 22.65%, geo_proximity 18.36%. Тесты: новый tests/test_matching_tier_reachability_2674.py сверяет параметры матчера с границей вызова (Protocol + адаптер) — ловит класс «ветка есть, передать некому», который обычный тест не видит, потому что зовёт функцию напрямую. Удалены два теста мёртвого fias-тира: они были зелёными ровно потому, что обходили границу вызова. Refs #2674
This commit is contained in:
parent
5e92810d72
commit
3fd6550a16
9 changed files with 275 additions and 115 deletions
|
|
@ -40,7 +40,9 @@ class SearchParams(BaseModel):
|
||||||
floors_total_max: int | None = Field(default=None, ge=1)
|
floors_total_max: int | None = Field(default=None, ge=1)
|
||||||
|
|
||||||
# --- Quality / cross-source ---
|
# --- 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
|
sources: list[Literal["avito", "cian", "yandex_realty"]] | None = None
|
||||||
multi_source_only: bool = False
|
multi_source_only: bool = False
|
||||||
require_avito: bool = False
|
require_avito: bool = False
|
||||||
|
|
|
||||||
|
|
@ -35,11 +35,13 @@ WHAT this is:
|
||||||
TWO PASSES (2026-07-02 follow-up): the SAME cluster→keeper→re-point→carry-identity→delete
|
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
|
pipeline now runs TWICE inside one transaction, parametrised by the cluster-key expression
|
||||||
(`_mapping_sql`) — no 300-line copy-paste:
|
(`_mapping_sql`) — no 300-line copy-paste:
|
||||||
1. FIAS pass — clusters by lower(NULLIF(house_fias_id, '')) (the ФИАС/ГАР building UUID,
|
1. FIAS pass — clusters by lower(COALESCE(house_fias_id, gar_house_guid)) — the ФИАС/ГАР
|
||||||
DaData-backfilled 2026-07-02 for 5 122 houses). Only non-null fias shared by >1 house_id
|
building UUID, whichever source filled it (DaData estimate-путь / ГАР-лоадер; #2674
|
||||||
cluster. Catches duplicates the canon MISSES: slash-collapse («Сулимова, 32» vs
|
расширил ключ со второго слагаемого, детали у _FIAS_KEY_EXPR). Only non-null UUIDs
|
||||||
«Сулимова, 3/2» → same canon «сулимова32») and посёлок truncation (Кедровка/Шувакиш
|
shared by >1 house_id cluster. Catches duplicates the canon MISSES: slash-collapse
|
||||||
colliding with a same-named ЕКБ street).
|
(«Сулимова, 32» vs «Сулимова, 3/2» → same canon «сулимова32»), посёлок truncation
|
||||||
|
(Кедровка/Шувакиш colliding with a same-named ЕКБ street) и — основная масса — те, что
|
||||||
|
канон-проход УЗНАЁТ, но не сливает из-за гео-стража (битая/пустая координата).
|
||||||
2. CANON pass — the canonical-address clustering above, now with a CROSS-FIAS GUARD: within a
|
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
|
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
|
DIFFERENT house_fias_id — provably different buildings the canon collapsed (the
|
||||||
|
|
@ -145,10 +147,25 @@ _KEEPER_ORDER = f"""
|
||||||
#
|
#
|
||||||
# FIAS key (#1772 follow-up, 2026-07-02): the ФИАС/ГАР building UUID (DaData backfill). Catches
|
# 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 посёлок
|
# duplicates the canon misses — slash-collapse («Сулимова, 32» vs «Сулимова, 3/2») and посёлок
|
||||||
# truncation (Кедровка/Шувакиш). Only non-null fias clusters.
|
# 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 → сломанная координата
|
||||||
|
# заодно чинится).
|
||||||
_FIAS_KEY_EXPR = """
|
_FIAS_KEY_EXPR = """
|
||||||
CASE WHEN NULLIF(house_fias_id, '') IS NOT NULL
|
CASE WHEN COALESCE(NULLIF(house_fias_id, ''), NULLIF(gar_house_guid, '')) IS NOT NULL
|
||||||
THEN 'fias:' || lower(house_fias_id)
|
THEN 'fias:'
|
||||||
|
|| lower(COALESCE(NULLIF(house_fias_id, ''), NULLIF(gar_house_guid, '')))
|
||||||
END
|
END
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -170,8 +187,9 @@ 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.
|
"""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
|
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 (a no-op for the fias pass, where every clustered
|
are rn>1. The CROSS-FIAS guard always applies. С расширенным ключом (#2674) он перестал быть
|
||||||
row shares one fias by construction).
|
no-op'ом для fias-прохода: кластер, собранный по общему gar_house_guid, может содержать строки
|
||||||
|
с РАЗНЫМ непустым house_fias_id — такую пару guard блокирует (консервативно, 1 случай на проде).
|
||||||
|
|
||||||
apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True.
|
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
|
- CANON pass → True: the canon strips город/район, so same-street-number buildings in
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,35 @@
|
||||||
"""House cross-source matching — tiered algorithm.
|
"""House cross-source matching — tiered algorithm.
|
||||||
|
|
||||||
Tier 0 (confidence 1.0): cadastral_number exact match on houses table.
|
`match_or_create_house` (путь скрейпинга, создаёт дома):
|
||||||
Tier 0.5 (confidence 0.95): house_fias_id (ГАР OBJECTGUID) exact match, case-insensitive.
|
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 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 2 (confidence 0.9): address_fingerprint match in house_address_aliases.
|
||||||
Tier 3 (confidence 0.7): geo-proximity within 30 m (PostGIS ST_DWithin).
|
Tier 3 (confidence 0.7): geo-proximity within 30 m (PostGIS ST_DWithin).
|
||||||
New (confidence 1.0): INSERT new canonical house.
|
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. Оставлен как рабочий приёмник на случай, если площадка
|
||||||
|
начнёт отдавать настоящий кадастр (парсер Циана уже пишет оба поля).
|
||||||
|
|
||||||
Algorithm reference: decisions/Cross_Source_Matching_Strategy.md sec 3
|
Algorithm reference: decisions/Cross_Source_Matching_Strategy.md sec 3
|
||||||
"""
|
"""
|
||||||
|
|
@ -47,7 +71,6 @@ def match_or_create_house(
|
||||||
year_built: int | None = None,
|
year_built: int | None = None,
|
||||||
building_cadastral_number: str | None = None,
|
building_cadastral_number: str | None = None,
|
||||||
cadastral_number: str | None = None,
|
cadastral_number: str | None = None,
|
||||||
house_fias_id: str | None = None,
|
|
||||||
source_url: str | None = None,
|
source_url: str | None = None,
|
||||||
) -> tuple[int | None, float, str]:
|
) -> tuple[int | None, float, str]:
|
||||||
"""Match existing house or create new canonical record.
|
"""Match existing house or create new canonical record.
|
||||||
|
|
@ -58,21 +81,18 @@ def match_or_create_house(
|
||||||
for an unknown address could both miss Tier 0-3 and each INSERT a duplicate
|
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.
|
house row. Closes finding #1 from 2026-05-24 audit.
|
||||||
|
|
||||||
Args:
|
NB: параметра `house_fias_id` здесь НЕТ намеренно (#2674) — см. шапку модуля.
|
||||||
house_fias_id: ГАР OBJECTGUID (UUID) of the building, when known upstream
|
ФИАС-тир живёт только в `match_house_readonly`, у которого есть источник ФИАС.
|
||||||
(e.g. DaData /clean/address). Enables Tier 0.5 fias_exact — additive and
|
|
||||||
optional, existing callers are unaffected.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(house_id, confidence ∈ [0.0, 1.0], method ∈ {
|
(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'
|
'geo_proximity', 'new', 'no_house_number'
|
||||||
})
|
})
|
||||||
house_id is None only for the 'no_house_number' terminal case below.
|
house_id is None only for the 'no_house_number' terminal case below.
|
||||||
|
|
||||||
Method values:
|
Method values:
|
||||||
'cadastr_exact' — matched by cadastral number (confidence 1.0)
|
'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)
|
'source_exact' — already in house_sources for this source+ext_id (confidence 1.0)
|
||||||
'fingerprint' — matched by address fingerprint (confidence 0.9)
|
'fingerprint' — matched by address fingerprint (confidence 0.9)
|
||||||
'geo_proximity' — matched by geo within 30 m (confidence 0.7)
|
'geo_proximity' — matched by geo within 30 m (confidence 0.7)
|
||||||
|
|
@ -136,34 +156,10 @@ def match_or_create_house(
|
||||||
logger.info("house match cadastr_exact house_id=%s cad=%s", house_id, cad)
|
logger.info("house match cadastr_exact house_id=%s cad=%s", house_id, cad)
|
||||||
return (house_id, 1.0, "cadastr_exact")
|
return (house_id, 1.0, "cadastr_exact")
|
||||||
|
|
||||||
# Tier 0.5: house_fias_id (ГАР OBJECTGUID) exact match, case-insensitive.
|
# Tier 0.5 fias_exact удалён (#2674): передать `house_fias_id` в этот путь было
|
||||||
# Stable ORDER BY id so concurrent/duplicate rows resolve deterministically.
|
# некому — ни Protocol HouseMatcher, ни RealMatcherAdapter, ни оба прямых вызывающих
|
||||||
if house_fias_id:
|
# такого параметра не имели, поэтому за всю историю тир не сработал ни разу (0 из
|
||||||
row = (
|
# 49 502 house_sources). Живой ФИАС-тир остался в match_house_readonly.
|
||||||
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 1: source+ext_id already registered in house_sources
|
# Tier 1: source+ext_id already registered in house_sources
|
||||||
row = (
|
row = (
|
||||||
|
|
|
||||||
|
|
@ -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)")
|
where.append("total_floors <= CAST(:fl_total_max AS integer)")
|
||||||
args["fl_total_max"] = params.floors_total_max
|
args["fl_total_max"] = params.floors_total_max
|
||||||
|
|
||||||
if params.has_kadastr:
|
# Фильтр has_kadastr удалён (#2674): `listings.cadastral_number` (кадастр КВАРТИРЫ)
|
||||||
where.append("cadastral_number IS NOT NULL")
|
# пуст у всех 93 408 объявлений — площадки его не отдают (единственный писатель,
|
||||||
|
# парсер Циана, читает offer["cadastralNumber"], которого в ответе нет). Предикат
|
||||||
|
# `cadastral_number IS NOT NULL` мог вернуть только пустую выдачу, т.е. обещал
|
||||||
|
# качество данных, которого нет. Колонка и её писатель оставлены: если площадка
|
||||||
|
# начнёт отдавать кадастр, заполнение заработает само — тогда и вернём фильтр.
|
||||||
|
|
||||||
segment_clause = _SEGMENT_SQL[params.segment]
|
segment_clause = _SEGMENT_SQL[params.segment]
|
||||||
if segment_clause is not None:
|
if segment_clause is not None:
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,18 @@ APPROXIMATION (deliberate first increment):
|
||||||
This is a GEO-NEAREST match — a street-level-geocoded listing is matched to the nearest
|
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.
|
cadastral building within `threshold_m`, NOT necessarily its exact cadastral building.
|
||||||
The threshold is always logged. Exact cadastral resolution + parcel-containment are
|
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
|
deferred (cad_parcels FDW not exposed).
|
||||||
treats building_cadastral_number as a hint, not ground truth, so an approximate fill is
|
|
||||||
a net win over 0% coverage.
|
ЭТО 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'):
|
Pipeline (one combined run, scheduler source='cadastral_geo_match'):
|
||||||
1. refresh_cad_buildings_local(db) — TRUNCATE + bulk INSERT from FDW (one scan).
|
1. refresh_cad_buildings_local(db) — TRUNCATE + bulk INSERT from FDW (one scan).
|
||||||
|
|
|
||||||
|
|
@ -240,18 +240,27 @@ def test_no_fstring_or_format_in_parametrised_sql() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_fias_pass_clusters_by_house_fias_id() -> None:
|
def test_fias_pass_clusters_by_house_fias_id() -> None:
|
||||||
"""A second cluster key: lower(NULLIF(house_fias_id,'')) — the ФИАС/ГАР building UUID.
|
"""A second cluster key: the ФИАС/ГАР building UUID from EITHER column (#2674).
|
||||||
|
|
||||||
The fias pass catches duplicates the address canon misses (slash-collapse «Сулимова, 32» vs
|
The fias pass catches duplicates the address canon misses (slash-collapse «Сулимова, 32» vs
|
||||||
«Сулимова, 3/2»; посёлок truncation). Only non-null fias shared by >1 house_id clusters.
|
«Сулимова, 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).
|
||||||
"""
|
"""
|
||||||
flat = _flat(_FIAS_MAPPING_SQL)
|
flat = _flat(_FIAS_MAPPING_SQL)
|
||||||
assert "'fias:' || lower(house_fias_id)" in flat
|
coalesce = "COALESCE(NULLIF(house_fias_id, ''), NULLIF(gar_house_guid, ''))"
|
||||||
assert "NULLIF(house_fias_id, '') IS NOT NULL" in flat
|
assert f"'fias:' || lower({coalesce})" in flat
|
||||||
|
assert f"{coalesce} IS NOT NULL" in flat
|
||||||
|
# ГАР-GUID обязан быть частью ключа — иначе половина заполненных идентичностей вне прохода.
|
||||||
|
assert "gar_house_guid" in flat
|
||||||
# same collision-safe pipeline (same temp table + >1 grouping), NOT the canon cluster key.
|
# same collision-safe pipeline (same temp table + >1 grouping), NOT the canon cluster key.
|
||||||
assert "CREATE TEMP TABLE _1772_dup_mapping" in flat
|
assert "CREATE TEMP TABLE _1772_dup_mapping" in flat
|
||||||
assert "HAVING count(*) > 1" in flat
|
assert "HAVING count(*) > 1" in flat
|
||||||
# the fias pass keys on the fias UUID, never on the 'addr:' canon key.
|
# the fias pass keys on the building UUID, never on the 'addr:' canon key.
|
||||||
assert "'addr:'" not in flat
|
assert "'addr:'" not in flat
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1013,3 +1022,84 @@ def test_real_fias_pass_ignores_geo_guard() -> None:
|
||||||
db.execute(_t("DELETE FROM houses WHERE id BETWEEN 900030 AND 900035"))
|
db.execute(_t("DELETE FROM houses WHERE id BETWEEN 900030 AND 900035"))
|
||||||
db.commit()
|
db.commit()
|
||||||
db.close()
|
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()
|
||||||
|
|
|
||||||
|
|
@ -283,57 +283,13 @@ def test_match_house_tier0_cadastr():
|
||||||
assert method == "cadastr_exact"
|
assert method == "cadastr_exact"
|
||||||
|
|
||||||
|
|
||||||
def test_match_house_tier05_fias_exact():
|
# Тесты Tier 0.5 fias_exact для match_or_create_house удалены вместе с самим тиром (#2674):
|
||||||
"""Tier 0.5: house_fias_id match → fias_exact (conf 0.95) before source/fp/geo.
|
# передать туда `house_fias_id` не мог ни один вызывающий (ни Protocol HouseMatcher, ни
|
||||||
|
# RealMatcherAdapter такого параметра не имели), поэтому за всю историю тир не сработал ни разу —
|
||||||
No cadastral_number supplied → Tier 0 skipped; the fias SELECT is the first
|
# 0 из 49 502 строк house_sources. Тесты при этом были зелёными: они звали функцию напрямую и
|
||||||
lookup after the advisory lock. On hit, _upsert_house_source + _insert_alias
|
# передавали параметр сами, минуя границу вызова. Ровно этот класс ошибок теперь сторожит
|
||||||
fire (address carries a house number so the alias is registered).
|
# tests/test_matching_tier_reachability_2674.py (сверка сигнатуры с границей вызова).
|
||||||
"""
|
# Живой fias-тир остался в match_house_readonly и покрыт тестами estimate-пути.
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
def test_match_house_tier1_source_exact():
|
def test_match_house_tier1_source_exact():
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""Достижимость тиров матчинга домов (#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)
|
||||||
|
|
@ -39,7 +39,6 @@ def test_build_query_all_filters():
|
||||||
area_m2_max=80,
|
area_m2_max=80,
|
||||||
price_rub_max=10_000_000,
|
price_rub_max=10_000_000,
|
||||||
year_built_min=2000,
|
year_built_min=2000,
|
||||||
has_kadastr=True,
|
|
||||||
multi_source_only=True,
|
multi_source_only=True,
|
||||||
require_avito=True,
|
require_avito=True,
|
||||||
address_query="Малышева",
|
address_query="Малышева",
|
||||||
|
|
@ -49,13 +48,28 @@ def test_build_query_all_filters():
|
||||||
assert "total_area >= CAST(:area_min AS double precision)" in sql
|
assert "total_area >= CAST(:area_min AS double precision)" in sql
|
||||||
assert "price_rub <= CAST(:price_max AS bigint)" in sql
|
assert "price_rub <= CAST(:price_max AS bigint)" in sql
|
||||||
assert "year_built >= CAST(:yb_min AS integer)" 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 "source_count >= 2" in sql
|
||||||
assert "has_avito = true" in sql
|
assert "has_avito = true" in sql
|
||||||
assert "address ILIKE" in sql
|
assert "address ILIKE" in sql
|
||||||
assert args["addr_like"] == "%Малышева%"
|
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():
|
def test_build_query_sources_array():
|
||||||
sql, args = build_search_query(SearchParams(sources=["avito", "cian"]))
|
sql, args = build_search_query(SearchParams(sources=["avito", "cian"]))
|
||||||
assert "sources && CAST(:sources AS text[])" in sql
|
assert "sources && CAST(:sources AS text[])" in sql
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue