fix(tradein/matching): город развёртки доезжает до стража Tier-2a, адрес дозаполняется
All checks were successful
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m1s

Бескоординатная карточка областного формата («ул. Кирова,4» — города в адресе НЕТ)
садилась в одноимённый дом другого города: ключ Tier-2a = sha256(адрес|lat|lon), без
координат он вырождается в один нормализованный адрес и становится глобально уникальным
в house_address_aliases. Прод 2026-08-10: 7303 из 21603 алиасов — такие ключи, 6047 из них
без токена города вовсе. Страж от этой коллизии в матчере был, но выводил город из ТЕКСТА
адреса (resolve_city_token), а областной SERP города не называет — сам код это и описывал
как «residual, needs sweep-context».

Sweep-context был собран и не доезжал: save_listings знает город-цель batch'а, прогоняет
его через свой гео-guard и пишет в listings.city, а _link_listing_to_house города не
получал и у match_or_create_house такого параметра не было. Признак лежал в соседней
колонке той же строки. Теперь он идёт через границу вызова (Protocol → адаптер → хук),
и его достижимость сторожит тест по образцу #2674 — структурной проверки сигнатуры мало.

Город здесь — НЕЗАВИСИМОЕ наблюдение (какой город запрашивала развёртка), а не второе
поле, выведенное из той же строки адреса: ровно то, на чём в #2690 сорвалось усиление
ключа через gar_house_guid.

Отдельно: listings.address не обновлялся при ON CONFLICT вообще — строка, вставленная без
адреса, оставалась безадресной навсегда, даже когда следующий скрейп адрес приносил (прод:
862 строки). Это и есть «привязка при пустом адресе» из #2777: 95 таких строк имеют
house_id_fk, потому что в момент матчинга адрес БЫЛ (fingerprint/new без адреса структурно
невозможны — алиаса с пустым ключом на проде 0). Дозаполняем COALESCE(existing, new) —
перезапись откатила бы чистку адресов миграциями 062/108/124.

Потолок правки назван числом в коде: 30 из 35 межгородских склеек закрываются, 5 — нет
(приходящая карточка ЕКБ, алиас завёл областной дом; города владельца алиаса мы не знаем).
Посёлки внутри ЕКБ-развёртки (Кедровка, 12 км) не ловятся вовсе — гранулярность признака
город, не населённый пункт. Уже сшитые дома НЕ трогаются.

Refs #2777
This commit is contained in:
bot-backend 2026-08-10 13:24:47 +05:00
parent 27e199e370
commit f2b56c52b1
8 changed files with 215 additions and 13 deletions

View file

@ -76,6 +76,7 @@ def match_or_create_house(
year_built: int | None = None,
building_cadastral_number: str | None = None,
source_url: str | None = None,
city: str | None = None,
) -> tuple[int | None, float, str]:
"""Match existing house or create new canonical record.
@ -88,6 +89,18 @@ def match_or_create_house(
NB: параметра `house_fias_id` здесь НЕТ намеренно (#2674) — см. шапку модуля.
ФИАС-тир живёт только в `match_house_readonly`, у которого есть источник ФИАС.
Args:
city: город-цель развёртки, собравшей эту карточку (`save_listings(city=)`,
он же `listings.city`) НЕЗАВИСИМОЕ от строки адреса наблюдение города
(#2777). Нужен ровно там, где адресный токен города бессилен: областной
формат Avito SERP «ул. Кирова,4» города не называет, а бескоординатный
ключ Tier-2a вырождается в один нормализованный адрес и становится
глобально уникальным. Опционален: вызывающие без sweep-контекста
(estimate-путь, ad-hoc скрипты) передают None поведение прежнее.
Про независимость: в #2690 доказано, что усиление ключа полем, выведенным
из ТОЙ ЖЕ строки адреса (gar_house_guid), защиту отменяет, а не усиливает
здесь признак приходит другим каналом (какой город запрашивала развёртка).
Returns:
(house_id, confidence [0.0, 1.0], method {
'cadastr_exact', 'source_exact', 'fingerprint',
@ -212,18 +225,40 @@ def match_or_create_house(
# SAME oblast building) still needs city-keyed aliases — a separate follow-up, out of
# scope, only relevant once the oblast sweep is enabled.
#
# EKB happy-path is byte-identical: the guard fires ONLY when the address names a non-ЕКБ
# city AND no coords disambiguate. ЕКБ cards (resolved city = екатеринбург) and the
# dominant bare/city-less Avito coord-less cards (resolved city None) run Tier-2a/2b
# exactly as before. NB: a BARE oblast card (no city token in the address — today's Avito
# SERP format) carries no signal here and is deliberately left on the unchanged path; that
# residual needs sweep-context and is out of this fix's scope.
_resolved_city = resolve_city_token(norm_addr) if (lat is None and lon is None) else None
# EKB happy-path is byte-identical: the guard fires ONLY when the card's city is known to
# be non-ЕКБ AND no coords disambiguate. ЕКБ cards and cards with no city signal at all
# (resolved city None) run Tier-2a/2b exactly as before.
#
# #2777: the residual the comment above used to describe as out of scope — a BARE oblast
# card ('ул. Кирова,4', today's Avito SERP format) — is closed here by the `city` kwarg.
# The sweep already knows which city it was crawling and stamps it on the listing row
# (save_listings → listings.city); that observation just never reached this guard, so
# 26 of 26 measured cross-city stitches went through Tier 2a on a coord-less key. Prod
# 2026-08-10: 7303 of 21603 aliases are coord-less keys, 6047 of them carry no city token
# at all — i.e. a globally unique 'street + number' that ANY city's card can hit.
# The address token still wins when present (it describes THIS card; the sweep city
# describes the batch).
_resolved_city = None
if lat is None and lon is None:
_resolved_city = resolve_city_token(norm_addr) or (normalize_address(city) or None)
_skip_oblast_alias = _resolved_city is not None and _resolved_city != EKB_CITY_TOKEN
# Известный потолок правки, названный числом (прод 2026-08-10, 35 домов со
# «сшитыми» городами по метке listings.city):
# • 30 из 35 — приходящая карточка областная, алиас принадлежит дому другого
# города → страж срабатывает;
# • 5 из 35 — приходящая карточка ЕКБ, а алиас завёл областной дом. Тут страж
# молчит: города владельца алиаса мы не знаем (в house_address_aliases его
# нет). Апгрейд — city-ключ у алиаса, но это миграция + перекладка 7303
# бескоординатных ключей, и до неё нужен журнал слияний (#2690 п.1).
# • посёлки внутри ЕКБ-развёртки (Кедровка, Б. Седельниково, Решёты — 12-17 км
# разброса) этим признаком НЕ ловятся вовсе: у них тот же город-цель
# «Екатеринбург». Гранулярность независимого наблюдения — город, не населённый
# пункт; это ограничение данных, а не недоделка стража.
if _skip_oblast_alias:
logger.info(
"house tier2a/2b skip: coord-less non-ЕКБ city %r na=%r src=%s",
"house tier2a/2b skip: coord-less non-ЕКБ city %r (sweep_city=%r) na=%r src=%s",
_resolved_city,
city,
norm_addr,
ext_source,
)

View file

@ -67,6 +67,7 @@ class RealMatcherAdapter:
year_built: int | None = None,
building_cadastral_number: str | None = None,
source_url: str | None = None,
city: str | None = None,
) -> tuple[int | None, float, str]:
# house_id is None when the matcher refuses a numberless address without a
# cadastral number (method 'no_house_number', P1). Callers must tolerate None.
@ -80,6 +81,7 @@ class RealMatcherAdapter:
year_built=year_built,
building_cadastral_number=building_cadastral_number,
source_url=source_url,
city=city,
)
def upsert_listing_source(

View file

@ -57,8 +57,9 @@ def _mock_db_bti(
address: str | None = "Екатеринбург, улица Малышева, 51",
lat: float = 56.83,
lon: float = 60.6,
city: str | None = "Екатеринбург",
) -> MagicMock:
"""Mock db: SELECT address/lat/lon FROM listings → mappings().first() dict."""
"""Mock db: SELECT address/city/lat/lon FROM listings → mappings().first() dict."""
db = MagicMock()
@contextmanager
@ -72,9 +73,10 @@ def _mock_db_bti(
mock_result = MagicMock()
mock_result.fetchone.return_value = None
mock_result.scalar_one_or_none.return_value = None
if "SELECT address, lat, lon FROM listings" in sql_str:
if "SELECT address, city, lat, lon FROM listings" in sql_str:
mock_result.mappings.return_value.first.return_value = {
"address": address,
"city": city,
"lat": lat,
"lon": lon,
}
@ -110,6 +112,9 @@ def test_bti_data_present_updates_house_columns_via_coalesce():
assert kwargs["ext_source"] == "cian_bti"
assert kwargs["ext_id"] == "123"
assert kwargs["address"] == "Екатеринбург, улица Малышева, 51"
# #2777: город-цель развёртки (listings.city) — независимое от адреса наблюдение;
# без него бескоординатная BTI-карточка матчится в одноимённый дом другого города.
assert kwargs["city"] == "Екатеринбург"
call = _bti_update_call(db)
assert call is not None, "UPDATE houses с BTI-полями не найден"

View file

@ -844,6 +844,111 @@ def test_tier2a_coord_less_bare_street_still_runs_tier2a():
assert any("fingerprint = :fp" in s for s in _executed_sqls(db))
# ---------------------------------------------------------------------------
# match_or_create_house — sweep-city guard for BARE oblast cards (#2777)
# ---------------------------------------------------------------------------
def test_tier2a_bare_card_from_oblast_sweep_skips_alias_lookups():
"""RED до #2777. Бескоординатная карточка областного формата («ул. Кирова,4» — города
в адресе НЕТ) собрана развёрткой Серова. Ключ Tier-2a без координат вырождается в один
нормализованный адрес, глобально уникальный в house_address_aliases, поэтому карточка
села бы в одноимённый дом другого города (прод: 362.8 км, дом 380628). Признак города у
развёртки ЕСТЬ он же пишется в listings.city и теперь доезжает до стража."""
from app.services.matching.houses import match_or_create_house
db = _make_db(
[
None, # pg_advisory_xact_lock
None, # house_sources miss (Tier 1)
# Tier 2a/2b SKIPPED стражем, Tier 3 SKIPPED (нет координат)
{"id": 2777}, # INSERT RETURNING id (New house)
None, # _upsert_house_source
None, # _insert_alias
]
)
house_id, conf, method = match_or_create_house(
db, "avito", "ext-2777-serov", address="ул. Кирова,4", city="Серов"
)
assert (house_id, conf, method) == (2777, 1.0, "new")
sqls = _executed_sqls(db)
assert not any(
"fingerprint = :fp" in s for s in sqls
), "карточка чужого города прошла Tier-2a по бескоординатному ключу «улица + номер»"
assert not any("normalized_address = :na" in s for s in sqls)
def test_sweep_city_ekb_keeps_tier2a_dedup():
"""Контроль к предыдущему: тот же бескоординатный ключ, но развёртка ЕКБ — страж молчит,
Tier-2a дедуп работает как раньше. Иначе правка ломала бы 31 663 ЕКБ-карточки ради 35."""
from app.services.matching.houses import match_or_create_house
db = _make_db(
[
None, # pg_advisory_xact_lock
None, # house_sources miss
{"house_id": 55}, # Tier 2a fingerprint HIT
None, # _upsert_house_source
None, # _insert_alias
]
)
house_id, conf, method = match_or_create_house(
db, "avito", "ext-2777-ekb", address="ул. Кирова,4", city="Екатеринбург"
)
assert (house_id, conf, method) == (55, 0.9, "fingerprint")
assert any("fingerprint = :fp" in s for s in _executed_sqls(db))
def test_sweep_city_ignored_when_card_has_coords():
"""Карточка С координатами стражем города не трогается: у Tier-2a координаты уже в ключе,
у Tier-2b свой гео-страж 3 км. Расширять на неё городской признак значит ломать
смежные ЕКБ/В.Пышма пары, которые в проде расходятся на 2-8 м (то же здание)."""
from app.services.matching.houses import match_or_create_house
db = _make_db(
[
None, # pg_advisory_xact_lock
None, # house_sources miss
{"house_id": 66}, # Tier 2a fingerprint HIT (координаты в ключе)
None, # _upsert_house_source
None, # _insert_alias
]
)
house_id, conf, method = match_or_create_house(
db,
"avito",
"ext-2777-coords",
address="ул. Кирова,4",
lat=59.60,
lon=60.58,
city="Верхняя Пышма",
)
assert (house_id, conf, method) == (66, 0.9, "fingerprint")
assert any("fingerprint = :fp" in s for s in _executed_sqls(db))
def test_sweep_city_guard_covers_cities_outside_the_token_list():
"""Страж не должен зависеть от списка _CITY_TOKENS: развёртка Ревды (её в списке нет)
всё равно не имеет права садиться на чужой алиас. Признак имя города от развёртки,
а не токен, вычитанный из адреса."""
from app.services.matching.houses import match_or_create_house
db = _make_db(
[
None, # pg_advisory_xact_lock
None, # house_sources miss
{"id": 2778}, # INSERT RETURNING id (New house) — алиасы пропущены
None, # _upsert_house_source
None, # _insert_alias
]
)
house_id, _conf, method = match_or_create_house(
db, "avito", "ext-2777-revda", address="ул. Кирова,4", city="Ревда"
)
assert (house_id, method) == (2778, "new")
assert not any("fingerprint = :fp" in s for s in _executed_sqls(db))
# ---------------------------------------------------------------------------
# match_or_create_listing — mock DB tier routing
# ---------------------------------------------------------------------------

View file

@ -107,3 +107,27 @@ def test_house_key_never_accepts_flat_cadastre() -> None:
assert (
"cad = building_cadastral_number\n" in src
), "в ключ дома вернулся фолбэк на кадастр квартиры"
def test_sweep_city_actually_reaches_the_matcher_from_save_listings() -> None:
"""Страж города бесполезен, пока признак не доезжает до него с настоящего вызова (#2777).
Тот же класс ошибки, что у `house_fias_id`: параметр в сигнатуре есть, передать его
некому. Здесь граница `scraper_kit.base._link_listing_to_house`, единственный боевой
вызывающий пути создания домов; город он получает из `save_listings`, который его же
пишет в `listings.city`.
"""
from scraper_kit.base import _link_listing_to_house, save_listings
assert "city" in _params(match_or_create_house)
hook_src = inspect.getsource(_link_listing_to_house)
assert "city=city" in hook_src, (
"_link_listing_to_house перестал передавать город в матчер — страж #2777 снова "
"недостижим, а межгородская склейка молча вернётся"
)
# save_listings считает lot_city (город batch'а после гео-guard'а) и обязан отдать
# именно его, а не сырой city-аргумент: лот вне city_radius_km города НЕ помечен.
assert "city=lot_city" in inspect.getsource(
save_listings
), "save_listings отдаёт матчеру не lot_city — гео-guard соседнего города обойдён"

View file

@ -601,6 +601,17 @@ def save_listings(
metro_stations = EXCLUDED.metro_stations,
listing_date = COALESCE(EXCLUDED.listing_date, listings.listing_date),
area_m2 = COALESCE(EXCLUDED.area_m2, listings.area_m2),
-- #2777: ДОзаполнение адреса — порядок аргументов обратный остальным,
-- существующее значение выигрывает. Адрес не обновлялся при конфликте
-- вообще: строка, вставленная без адреса (SERP-вариант его не дал),
-- оставалась безадресной НАВСЕГДА, даже когда следующий скрейп адрес
-- приносил. Прод 2026-08-10: 862 строки с address IS NULL, у 95 из них
-- при этом ЕСТЬ house_id_fk матчинг в тот раз получил адрес и сматчил
-- корректно (fingerprint/new без адреса невозможны), в колонке же
-- остался NULL, и он же кормит геокодер мусором. Перезаписывать НЕЛЬЗЯ:
-- миграции 062/108/124 чистят listings.address, свежий сырой адрес от
-- площадки молча откатил бы эту чистку.
address = COALESCE(listings.address, EXCLUDED.address),
-- #2594: город развёртки — COALESCE, чтобы caller без city (ad-hoc
-- admin/manual пути, city=None) не затирал уже известный город.
city = COALESCE(EXCLUDED.city, listings.city),
@ -714,6 +725,9 @@ def save_listings(
metro_stations = CAST(:metro_stations AS jsonb),
listing_date = COALESCE(:listing_date, listing_date),
area_m2 = COALESCE(:area_m2, area_m2),
-- #2777: см. ON CONFLICT выше — дозаполняем адрес,
-- существующее значение выигрывает.
address = COALESCE(address, :address),
city = COALESCE(:city, city),
kitchen_area_m2 = COALESCE(:kitchen_area_m2, kitchen_area_m2),
ceiling_height_m = COALESCE(:ceiling_height_m, ceiling_height_m),
@ -834,7 +848,7 @@ def save_listings(
if listing_id is not None:
try:
with db.begin_nested():
_link_listing_to_house(db, listing_id, lot, matcher)
_link_listing_to_house(db, listing_id, lot, matcher, city=lot_city)
matched += 1
except Exception as e:
# Best-effort hook: log and continue so the listings batch isn't aborted.
@ -872,7 +886,7 @@ def _to_json(value: Any) -> str:
def _link_listing_to_house(
db: Session, listing_id: int, lot: ScrapedLot, matcher: HouseMatcher
db: Session, listing_id: int, lot: ScrapedLot, matcher: HouseMatcher, *, city: str | None = None
) -> None:
"""Hook scraped listing into matching service: resolve house, upsert listing_sources.
@ -890,6 +904,12 @@ def _link_listing_to_house(
ext_id source: lot.source_id if present, else dedup_hash (Yandex without
stable source_id falls back to URL-based dedup_hash same hash on re-scrape).
`city` город-цель ЭТОГО batch'а после гео-guard'а (`lot_city` в save_listings,
он же попадает в `listings.city`). Отдаётся матчеру как независимое от строки адреса
наблюдение города (#2777): бескоординатный ключ Tier-2a вырождается в один
нормализованный адрес, а областной формат Avito SERP («ул. Кирова,4») города не
называет без этого признака карточка из Серова матчится в дом Каменска-Уральского.
Skips silently if:
- lot has no source_id AND no address/lat/lon (cannot match house anyway)
@ -925,6 +945,7 @@ def _link_listing_to_house(
# по-прежнему пишутся save_listings'ом, теряется только ложная идентичность.
building_cadastral_number=lot.building_cadastral_number,
source_url=lot.house_url or lot.source_url,
city=city,
)
# Mirror the resolved house into listings.house_id_fk so direct

View file

@ -66,9 +66,15 @@ class HouseMatcher(Protocol):
year_built: int | None = ...,
building_cadastral_number: str | None = ...,
source_url: str | None = ...,
city: str | None = ...,
) -> tuple[int | None, float, str]:
"""Найти или создать канонический дом.
NB (#2777): `city` — город-цель развёртки этой карточки (тот же, что уходит в
`listings.city`). Единственное наблюдение города, НЕ выведенное из строки адреса;
без него бескоординатная карточка областного формата («ул. Кирова,4») матчится в
одноимённый дом другого города.
NB (#2674): `cadastral_number` (кадастр КВАРТИРЫ) из контракта УБРАН. Ключ дома —
только `building_cadastral_number`; квартирный номер в нём породил бы по дому на
квартиру, см. комментарий у `cad` в matching/houses.py.

View file

@ -575,7 +575,10 @@ def _persist_cian_bti_house(
"""
row = (
db.execute(
text("SELECT address, lat, lon FROM listings WHERE id = CAST(:lid AS bigint)"),
# city (#2777): развёртка уже пометила строку городом-целью — единственное
# наблюдение города, не выведенное из строки адреса. Без него бескоординатная
# карточка садится в одноимённый дом другого города (Tier-2a).
text("SELECT address, city, lat, lon FROM listings WHERE id = CAST(:lid AS bigint)"),
{"lid": listing_id},
)
.mappings()
@ -595,6 +598,7 @@ def _persist_cian_bti_house(
address=row["address"],
lat=row["lat"],
lon=row["lon"],
city=row["city"],
)
if house_id is None:
logger.info(