fix(tradein/dedup): дома с районом, приклеенным к номеру («17Ар-н Академический»), сливаются со своими двойниками (#1772)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 19s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Failing after 24s
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 6m47s
Some checks failed
CI Trade-In / changes (pull_request) Successful in 19s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Failing after 24s
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 6m47s
Канон-ключ tradein_canon_addr не срезал район, приклеенный к номеру дома без запятой (наследие слипа «29р-н» из #1773): «ул. Евгения Савкова, 17Ар-н Академический» давал «евгениясавкова17арнакадемический», а «44Бр-н» терял литеру (токен-срез принимал «бр» за бульвар). Двойник «ул. Евгения Савкова,17А» с таким домом в один кластер не попадал, и перепись остатка его не видела. Миграция 311: тело 147 плюс шаг «<цифра>[литера]р-н <название>» → «<цифра>[литера]» сразу после lower. Якорь на цифре обязателен: без него «мкр-н Кутузовский, 2» превращается в «мк2». Прод 17.09 (read-only): канон меняется у 44 из 49 159 домов, у 20 появляется двойник в пределах 250 м, ГАР-сторона не затронута (0 из 3,27 млн строк с шаблоном). Тест по значению на функции, установленной миграциями, и сквозное слияние: склеенный адрес и чистый двойник сливаются, «17» без литеры остаётся отдельным. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
ceb2e5972d
commit
e2b1a8adee
3 changed files with 155 additions and 0 deletions
|
|
@ -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 м (0–222 м, все — тот же
|
||||
-- адрес в другом написании, напр. «Академика Вавилова, 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue