Compare commits
No commits in common. "f76485781b5f7e796e14d22f59cc91001f38d29b" and "5e92810d722513067febc8c5b97bf3801b4f0834" have entirely different histories.
f76485781b
...
5e92810d72
13 changed files with 110 additions and 244 deletions
|
|
@ -40,9 +40,7 @@ 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 снят (#2674): listings.cadastral_number пуст у всех 93 408 строк,
|
has_kadastr: bool = False
|
||||||
# фильтр мог вернуть только пустую выдачу. Лишний 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
|
||||||
|
|
|
||||||
|
|
@ -132,16 +132,9 @@ _COMPLETENESS_EXPR = """
|
||||||
|
|
||||||
# Keeper ORDER BY, shared by the ROW_NUMBER() rank and the first_value() keeper pick so they
|
# 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.
|
# 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"""
|
_KEEPER_ORDER = f"""
|
||||||
(h.geom IS NOT NULL) DESC,
|
(h.geom IS NOT NULL) DESC,
|
||||||
listing_cnt DESC NULLS LAST,
|
listing_cnt DESC,
|
||||||
({_COMPLETENESS_EXPR}) DESC,
|
({_COMPLETENESS_EXPR}) DESC,
|
||||||
h.id ASC
|
h.id ASC
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,11 @@
|
||||||
"""House cross-source matching — tiered algorithm.
|
"""House cross-source matching — tiered algorithm.
|
||||||
|
|
||||||
`match_or_create_house` (путь скрейпинга, создаёт дома):
|
Tier 0 (confidence 1.0): cadastral_number exact match on houses table.
|
||||||
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 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. Оставлен как рабочий приёмник на случай, если площадка
|
|
||||||
начнёт отдавать настоящий кадастр — но приёмник СУЖЕН до кадастра ЗДАНИЯ: параметр
|
|
||||||
`cadastral_number` (кадастр КВАРТИРЫ) убран из сигнатуры, Protocol и обоих вызывающих.
|
|
||||||
Он был отложенной миной: у каждой квартиры свой номер, Tier 0 не сматчил бы никогда,
|
|
||||||
падение в New-house INSERT записало бы номер квартиры в `houses.cadastral_number` и
|
|
||||||
попутно сняло P1-страж «безномерный адрес без кадастра не создаём» — по дому на
|
|
||||||
квартиру. В `listings` оба поля пишутся как раньше; из ключа дома ушло только ложное.
|
|
||||||
|
|
||||||
Algorithm reference: decisions/Cross_Source_Matching_Strategy.md sec 3
|
Algorithm reference: decisions/Cross_Source_Matching_Strategy.md sec 3
|
||||||
"""
|
"""
|
||||||
|
|
@ -75,6 +46,8 @@ 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,
|
||||||
|
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.
|
||||||
|
|
@ -85,18 +58,21 @@ 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.
|
||||||
|
|
||||||
NB: параметра `house_fias_id` здесь НЕТ намеренно (#2674) — см. шапку модуля.
|
Args:
|
||||||
ФИАС-тир живёт только в `match_house_readonly`, у которого есть источник ФИАС.
|
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.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(house_id, confidence ∈ [0.0, 1.0], method ∈ {
|
(house_id, confidence ∈ [0.0, 1.0], method ∈ {
|
||||||
'cadastr_exact', 'source_exact', 'fingerprint',
|
'cadastr_exact', 'fias_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)
|
||||||
|
|
@ -111,16 +87,7 @@ def match_or_create_house(
|
||||||
'р-н Чкаловский, мкр. Вторчермет' 480). A cadastral number is a precise building
|
'р-н Чкаловский, мкр. Вторчермет' 480). A cadastral number is a precise building
|
||||||
identity, so cad-carrying rows stay exempt (Tier 0 owns them).
|
identity, so cad-carrying rows stay exempt (Tier 0 owns them).
|
||||||
"""
|
"""
|
||||||
# ТОЛЬКО кадастр ЗДАНИЯ (#2674). Раньше было `building_cadastral_number or cadastral_number`,
|
cad = 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.
|
# Compute fingerprint early so we can acquire the advisory lock before any tier reads.
|
||||||
fp = address_fingerprint(address, lat, lon)
|
fp = address_fingerprint(address, lat, lon)
|
||||||
|
|
@ -169,10 +136,34 @@ 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 fias_exact удалён (#2674): передать `house_fias_id` в этот путь было
|
# Tier 0.5: house_fias_id (ГАР OBJECTGUID) exact match, case-insensitive.
|
||||||
# некому — ни Protocol HouseMatcher, ни RealMatcherAdapter, ни оба прямых вызывающих
|
# Stable ORDER BY id so concurrent/duplicate rows resolve deterministically.
|
||||||
# такого параметра не имели, поэтому за всю историю тир не сработал ни разу (0 из
|
if house_fias_id:
|
||||||
# 49 502 house_sources). Живой ФИАС-тир остался в match_house_readonly.
|
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 1: source+ext_id already registered in house_sources
|
# Tier 1: source+ext_id already registered in house_sources
|
||||||
row = (
|
row = (
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ class RealMatcherAdapter:
|
||||||
*,
|
*,
|
||||||
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,
|
||||||
source_url: str | None = None,
|
source_url: str | None = None,
|
||||||
) -> tuple[int | None, float, str]:
|
) -> tuple[int | None, float, str]:
|
||||||
# house_id is None when the matcher refuses a numberless address without a
|
# house_id is None when the matcher refuses a numberless address without a
|
||||||
|
|
@ -79,6 +80,7 @@ class RealMatcherAdapter:
|
||||||
lon,
|
lon,
|
||||||
year_built=year_built,
|
year_built=year_built,
|
||||||
building_cadastral_number=building_cadastral_number,
|
building_cadastral_number=building_cadastral_number,
|
||||||
|
cadastral_number=cadastral_number,
|
||||||
source_url=source_url,
|
source_url=source_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,12 +95,8 @@ 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
|
||||||
|
|
||||||
# Фильтр has_kadastr удалён (#2674): `listings.cadastral_number` (кадастр КВАРТИРЫ)
|
if params.has_kadastr:
|
||||||
# пуст у всех 93 408 объявлений — площадки его не отдают (единственный писатель,
|
where.append("cadastral_number IS NOT NULL")
|
||||||
# парсер Циана, читает 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,18 +15,9 @@ 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).
|
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
|
||||||
ЭТО HINT, И ТОЛЬКО HINT (#2674 — правка прежнего утверждения в этой шапке).
|
a net win over 0% coverage.
|
||||||
Раньше здесь было написано, что 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).
|
||||||
|
|
|
||||||
|
|
@ -291,8 +291,8 @@ def _link_listing_to_house(
|
||||||
lat=row.lat,
|
lat=row.lat,
|
||||||
lon=row.lon,
|
lon=row.lon,
|
||||||
year_built=row.year_built,
|
year_built=row.year_built,
|
||||||
# Кадастр КВАРТИРЫ (row.cadastral_number) в ключ дома не идёт (#2674).
|
|
||||||
building_cadastral_number=row.building_cadastral_number,
|
building_cadastral_number=row.building_cadastral_number,
|
||||||
|
cadastral_number=row.cadastral_number,
|
||||||
source_url=row.house_url or row.source_url,
|
source_url=row.house_url or row.source_url,
|
||||||
)
|
)
|
||||||
house_resolved = house_id is not None
|
house_resolved = house_id is not None
|
||||||
|
|
|
||||||
|
|
@ -106,24 +106,6 @@ def test_keeper_rule_priority_order() -> None:
|
||||||
assert col in hdm._COMPLETENESS_EXPR
|
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:
|
def test_mapping_selects_losers_only() -> None:
|
||||||
"""The mapping is loser_id→keeper_id; the keeper itself is excluded (rn>1, id<>keeper)."""
|
"""The mapping is loser_id→keeper_id; the keeper itself is excluded (rn>1, id<>keeper)."""
|
||||||
flat = _flat(_MAPPING_SQL)
|
flat = _flat(_MAPPING_SQL)
|
||||||
|
|
|
||||||
|
|
@ -276,21 +276,64 @@ def test_match_house_tier0_cadastr():
|
||||||
address="ул Ленина 5",
|
address="ул Ленина 5",
|
||||||
lat=56.8,
|
lat=56.8,
|
||||||
lon=60.5,
|
lon=60.5,
|
||||||
# Кадастр ЗДАНИЯ — единственный, что принимает ключ дома (#2674).
|
cadastral_number="66:41:0000000:999",
|
||||||
building_cadastral_number="66:41:0000000:999",
|
|
||||||
)
|
)
|
||||||
assert house_id == 42
|
assert house_id == 42
|
||||||
assert conf == 1.0
|
assert conf == 1.0
|
||||||
assert method == "cadastr_exact"
|
assert method == "cadastr_exact"
|
||||||
|
|
||||||
|
|
||||||
# Тесты Tier 0.5 fias_exact для match_or_create_house удалены вместе с самим тиром (#2674):
|
def test_match_house_tier05_fias_exact():
|
||||||
# передать туда `house_fias_id` не мог ни один вызывающий (ни Protocol HouseMatcher, ни
|
"""Tier 0.5: house_fias_id match → fias_exact (conf 0.95) before source/fp/geo.
|
||||||
# RealMatcherAdapter такого параметра не имели), поэтому за всю историю тир не сработал ни разу —
|
|
||||||
# 0 из 49 502 строк house_sources. Тесты при этом были зелёными: они звали функцию напрямую и
|
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
|
||||||
# tests/test_matching_tier_reachability_2674.py (сверка сигнатуры с границей вызова).
|
fire (address carries a house number so the alias is registered).
|
||||||
# Живой 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():
|
||||||
|
|
@ -606,9 +649,7 @@ def test_numberless_address_with_cadastral_creates_house():
|
||||||
"cian",
|
"cian",
|
||||||
"ext-cad",
|
"ext-cad",
|
||||||
address="екатеринбург улица мамина сибиряка",
|
address="екатеринбург улица мамина сибиряка",
|
||||||
# Именно кадастр ЗДАНИЯ снимает P1-страж: он идентифицирует дом. Кадастр КВАРТИРЫ этого
|
cadastral_number="66:41:0000000:12345",
|
||||||
# права лишён (#2674) — иначе номер квартиры открывал бы создание мега-бакетов.
|
|
||||||
building_cadastral_number="66:41:0000000:12345",
|
|
||||||
)
|
)
|
||||||
assert (house_id, conf, method) == (321, 1.0, "new")
|
assert (house_id, conf, method) == (321, 1.0, "new")
|
||||||
assert any(
|
assert any(
|
||||||
|
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
"""Достижимость тиров матчинга домов (#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
|
|
||||||
), "в ключ дома вернулся фолбэк на кадастр квартиры"
|
|
||||||
|
|
@ -39,6 +39,7 @@ 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="Малышева",
|
||||||
|
|
@ -48,28 +49,13 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -908,10 +908,8 @@ def _link_listing_to_house(
|
||||||
lat=lot.lat,
|
lat=lot.lat,
|
||||||
lon=lot.lon,
|
lon=lot.lon,
|
||||||
year_built=lot.year_built,
|
year_built=lot.year_built,
|
||||||
# Кадастр КВАРТИРЫ (lot.cadastral_number / lot.kadastr_num) сюда БОЛЬШЕ НЕ идёт
|
|
||||||
# (#2674) — ключ дома строится только на кадастре здания. В listings оба поля
|
|
||||||
# по-прежнему пишутся save_listings'ом, теряется только ложная идентичность.
|
|
||||||
building_cadastral_number=lot.building_cadastral_number,
|
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,
|
source_url=lot.house_url or lot.source_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,14 +65,11 @@ class HouseMatcher(Protocol):
|
||||||
*,
|
*,
|
||||||
year_built: int | None = ...,
|
year_built: int | None = ...,
|
||||||
building_cadastral_number: str | None = ...,
|
building_cadastral_number: str | None = ...,
|
||||||
|
cadastral_number: str | None = ...,
|
||||||
source_url: str | None = ...,
|
source_url: str | None = ...,
|
||||||
) -> tuple[int | None, float, str]:
|
) -> tuple[int | None, float, str]:
|
||||||
"""Найти или создать канонический дом.
|
"""Найти или создать канонический дом.
|
||||||
|
|
||||||
NB (#2674): `cadastral_number` (кадастр КВАРТИРЫ) из контракта УБРАН. Ключ дома —
|
|
||||||
только `building_cadastral_number`; квартирный номер в нём породил бы по дому на
|
|
||||||
квартиру, см. комментарий у `cad` в matching/houses.py.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(house_id, confidence ∈ [0.0, 1.0], method), где method ∈ {
|
(house_id, confidence ∈ [0.0, 1.0], method), где method ∈ {
|
||||||
'cadastr_exact', 'source_exact', 'fingerprint', 'geo_proximity', 'new',
|
'cadastr_exact', 'source_exact', 'fingerprint', 'geo_proximity', 'new',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue