Схлопывание дублей домов: ФИАС-проход больше не сливает дома из разных городов, а «17Ар-н Академический» находит своего двойника #3558

Merged
bot-backend merged 5 commits from fix/houses-dedup into main 2026-09-17 11:17:38 +00:00
3 changed files with 155 additions and 0 deletions
Showing only changes of commit e2b1a8adee - Show all commits

View file

@ -0,0 +1,77 @@
-- 311_canon_strip_district_glued_to_house_number.sql
--
-- CONTEXT (#1772): один дом живёт в нескольких записях houses, аналоги из того же дома
-- разъезжаются. Схлопывание house_dedup_merge кластеризует по tradein_canon_addr, а канон
-- промахивается на адресах, где район ПРИКЛЕЕН к номеру дома без запятой (наследие слипа
-- «29р-н» из #1773 — источник починен, но записанные дома сохранили склеенный адрес):
-- «Екатеринбург, ул. Евгения Савкова, 17Ар-н Академический» → «евгениясавкова17арнакадемический»
-- «Екатеринбург, ул. Евгения Савкова, 44Бр-н Академический» → «евгениясавкова44накадемический»
-- Во втором случае хуже, чем шум: токен-срез S5 принимает «бр» (бульвар) и съедает литеру Б.
-- Срез S4 (мигр. 147) снимает «р-н ...» только как отдельный сегмент перед запятой, поэтому
-- двойник «ул. Евгения Савкова,17А» (канон «евгениясавкова17а») с этим домом не кластеризуется
-- никогда, и перепись остатка (residual_*) такие пары не видит — у них РАЗНЫЙ канон.
--
-- WHAT: CREATE OR REPLACE FUNCTION tradein_canon_addr(text) — тело мигр. 147 плюс один шаг
-- S0b сразу после lower+ё→е: «<цифра>[литера]р-н <название>» до запятой/конца → «<цифра>[литера]».
-- Якорь на цифре обязателен: без него «мкр-н Кутузовский, 2» теряет «р-н ...» и превращается
-- в «мк2» (прод 17.09: срез «р-н [^,]*» без якоря менял канон у 1024 домов вместо 44; в выборке
-- 30 изменений вне шаблона 29 — «мкр-н …», одно — хвост «· рВерх-Исетский»).
-- Литера сохраняется: «17Ар-н» → «17а», «17А» не схлопывается с «17».
--
-- Замер на проде 2026-09-17 (read-only, выражение шага inline в SELECT):
-- houses: канон меняется у 44 из 49 159 — ровно у 44 домов с шаблоном «[0-9][а-я]?р-н »;
-- у 20 из них появляется двойник с тем же каноном в пределах 250 м (0222 м, все — тот же
-- адрес в другом написании, напр. «Академика Вавилова, 9р-н Академический» ↔ «улица
-- Академика Вавилова, 9», 0 м, 705 и 105 объявлений); разных ФИАС ни в одной паре нет.
-- gar_house_flats: 0 из 3,27 млн строк содержат шаблон → канон ГАР-стороны не меняется,
-- значения функционального индекса остаются верными.
--
-- DEPENDENCIES (existing prod objects):
-- - tradein_canon_addr(text) — мигр. 144/147 (заменяем тело, IMMUTABLE, сигнатура та же).
-- - gar_house_flats_canon_idx — функциональный индекс на tradein_canon_addr(norm_address)
-- (мигр. 144). По замеру значения не меняются, но REINDEX оставлен по правилу мигр. 147:
-- Postgres не перестраивает функциональный индекс при смене тела функции, и полагаться
-- на «сейчас ни одна строка не затронута» для следующей загрузки ГАР нельзя.
--
-- POST-DEPLOY: ничего запускать не нужно. Следующий прогон расписания house_dedup_merge
-- (еженедельно) сольёт пары, прошедшие страж 250 м, с журналом в house_merge_log.
--
-- SAFETY / IDEMPOTENCY: CREATE OR REPLACE + REINDEX INDEX — повторный прогон no-op. Раннер деплоя
-- гонит файл через psql ON_ERROR_STOP=on без --single-transaction → BEGIN/COMMIT в файле.
BEGIN;
CREATE OR REPLACE FUNCTION tradein_canon_addr(s text) RETURNS text AS $func$
SELECT regexp_replace( -- S6: оставить только [а-я0-9]
regexp_replace( -- S5: срез типов улиц (токены на границе не-кириллицы)
regexp_replace( -- S5b: срез маркера дома «д[.]» перед номером
regexp_replace( -- S4b: срез «мкр/пос/поселок ...,» только если следом улица
regexp_replace( -- S4: срез префикс-сегментов (каскад через lookahead)
regexp_replace( -- S3: срез суффикс-сегмента «<Имя> м-н,»
regexp_replace( -- S2: срез суффикс-сегмента «<...> обл/область,»
regexp_replace( -- S1b: «б-р» → «бульвар»
regexp_replace( -- S1a: «пр-т/пр-кт/пр кт/пркт» → «проспект»
regexp_replace( -- S0b: «17Ар-н Академический» → «17а» (#1772)
translate(lower(coalesce(s, '')), 'ё', 'е'), -- S0: lower + ё→е
'([0-9][а-я]?)р-н [^,]*', '\1', 'g'),
'(^|[^а-я])пр[-. ]?к?т([^а-я]|$)', '\1проспект\2', 'g'),
'(^|[^а-я])б-р([^а-я]|$)', '\1бульвар\2', 'g'),
'(^|,)[^,]* обл[а-я]*\.?(?=,)', '\1', 'g'),
'(^|,)[^,]* м-н(?=,)', '\1', 'g'),
'(^|,)\s*(россия|екатеринбург|город|жилой район|жилрайон|пгт|снт|р-н|м-н|г)([. ][^,]*)?(?=,)', '\1', 'g'),
'(^|,)\s*(мкр|поселок|пос)[. ][^,]*(?=,[^,]*[а-я])', '\1', 'g'),
'(^|[^а-я])д\.?(?= *[0-9])', '\1', 'g'),
'(^|[^а-я])(улица|ул|переулок|пер|проспект|пркт|пр|бульвар|бр|шоссе|ш|проезд|набережная|наб|площадь|пл|тупик|туп|аллея|микрорайон|мкр)([^а-я]|$)', '\1\3', 'g'),
'[^а-я0-9]', '', 'g');
$func$ LANGUAGE sql IMMUTABLE;
COMMENT ON FUNCTION tradein_canon_addr(text) IS
'Канонический ключ адреса для ГАР↔houses матча и схлопывания дублей домов (мигр. 144 + 147 + 311): '
'lower+ё→е, срез района, приклеенного к номеру дома («17Ар-н Академический» → «17а»), '
'нормализация проспект/бульвар (пр-т/пр-кт/б-р), срез гео-префиксов '
'(Россия/обл/Екатеринбург/г/мкр/м-н/пос/жилой район/р-н/снт/пгт) и маркера дома «д.», '
'затем срез типов улиц как токенов на границе не-кириллицы → оставляет только [а-я0-9].';
REINDEX INDEX gar_house_flats_canon_idx;
COMMIT;

View file

@ -34,6 +34,7 @@ tests/test_audit_api.py::test_real_accounts_and_analytics_aggregate_inserted_row
tests/test_gar_flats_loader.py::test_upsert_and_canon_match_populates_gar_flat_count
tests/test_gar_flats_loader.py::test_no_city_filter_ambiguous_canon_not_matched
tests/test_house_dedup_merge.py::test_real_canon_clusterkey_and_geo_guard_merge_semantics
tests/test_house_dedup_merge.py::test_real_canon_strips_district_glued_to_house_number
tests/test_house_dedup_merge.py::test_real_fias_pass_cross_guard_and_identity_carryover
tests/test_house_dedup_merge.py::test_real_fias_pass_keeps_geo_guard
tests/test_house_dedup_merge.py::test_real_merge_is_reversible_via_journal

View file

@ -994,6 +994,83 @@ def test_real_canon_clusterkey_and_geo_guard_merge_semantics() -> None:
db.close()
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_real_canon_strips_district_glued_to_house_number() -> None:
"""#1772: a district glued to the house number («17Ар-н Академический») is canon noise.
Prod 2026-09-17: 44 houses carry such an address (the «29р-н» slip of #1773); the canon kept
the tail («евгениясавкова17арнакадемический») or even ate the letter («44Бр-н» «44н»),
so the twin «ул. Евгения Савкова,17А» never shared a cluster and the merge could not see it.
Asserted on the function the migrations installed (311), not on a Python mirror of it.
"""
from sqlalchemy import text as _t
db = _live_session()
assert db is not None
def canon(s: str) -> str:
return db.execute(_t("SELECT tradein_canon_addr(:s)"), {"s": s}).scalar()
try:
assert canon("Екатеринбург, ул. Евгения Савкова, 17Ар-н Академический") == (
"евгениясавкова17а"
)
assert canon("ул. Евгения Савкова,17А") == "евгениясавкова17а"
assert canon("Екатеринбург, ул. Евгения Савкова, 44Бр-н Академический") == (
"евгениясавкова44б"
)
assert canon("Екатеринбург, Рассветная ул., 8/1р-н Кировский") == "рассветная81"
# the letter is a different building: «17А» never collapses into «17»
assert canon("ул. Евгения Савкова, 17") == "евгениясавкова17"
# the digit anchor keeps «мкр-н» intact (an unanchored «р-н …» strip gives «мк2»)
assert canon("Мкр-н Кутузовский, 2") == "нкутузовский2"
assert canon("р-н Ленинский, улица Цвиллинга, 7/6") == "цвиллинга76"
# End to end: the glued row and its clean twin (same point) merge; «17» without the
# letter, at the same point, stays a separate building.
db.execute(
_t(
"INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon) VALUES "
"(900040,'avito','EXT-1772-G','u',"
" 'Екатеринбург, ул. Савкова1772, 17Ар-н Академический',56.84,60.6),"
"(900041,'cian','EXT-1772-T','u','ул. Савкова1772,17А',56.84,60.6),"
"(900042,'cian','EXT-1772-N','u','ул. Савкова1772, 17',56.84,60.6)"
)
)
db.execute(
_t(
"INSERT INTO listings "
"(id, source, source_url, source_id, dedup_hash, price_rub, house_id_fk) VALUES "
"(910040,'avito','http://t/1772/g','L-1772-G','dh-1772-g',7000000,900040)"
)
)
db.commit()
out = hdm.merge_duplicate_houses(db, dry_run=False)
ids = {
r.id for r in db.execute(_t("SELECT id FROM houses WHERE id BETWEEN 900040 AND 900042"))
}
assert ids == {900040, 900042}, sorted(ids)
assert out["losers_deleted"] == 1
journal = db.execute(
_t(
"SELECT merge_pass, cluster_key, loser_id, keeper_id FROM house_merge_log "
"WHERE loser_id BETWEEN 900040 AND 900042"
)
).all()
assert [tuple(j) for j in journal] == [("canon", "addr:савкова177217а", 900041, 900040)]
finally:
db.rollback()
db.execute(_t("DELETE FROM listings WHERE id = 910040"))
db.execute(_t("DELETE FROM house_sources WHERE house_id BETWEEN 900040 AND 900042"))
db.execute(_t("DELETE FROM house_address_aliases WHERE house_id BETWEEN 900040 AND 900042"))
db.execute(_t("DELETE FROM house_merge_log WHERE loser_id BETWEEN 900000 AND 900299"))
db.execute(_t("DELETE FROM houses WHERE id BETWEEN 900040 AND 900042"))
db.commit()
db.close()
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_real_fias_pass_cross_guard_and_identity_carryover() -> None:
"""End-to-end on a real DB for the #1772 follow-up (fias pass + cross-fias guard + carry-over):