From e2b1a8adeec68099d916bcb3c8e16a46bf9a6e1d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:42:08 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein/dedup):=20=D0=B4=D0=BE=D0=BC=D0=B0?= =?UTF-8?q?=20=D1=81=20=D1=80=D0=B0=D0=B9=D0=BE=D0=BD=D0=BE=D0=BC,=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=BA=D0=BB=D0=B5=D0=B5=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=BC=20=D0=BA=20=D0=BD=D0=BE=D0=BC=D0=B5=D1=80=D1=83=20(?= =?UTF-8?q?=C2=AB17=D0=90=D1=80-=D0=BD=20=D0=90=D0=BA=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=BC=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B9=C2=BB),=20?= =?UTF-8?q?=D1=81=D0=BB=D0=B8=D0=B2=D0=B0=D1=8E=D1=82=D1=81=D1=8F=20=D1=81?= =?UTF-8?q?=D0=BE=20=D1=81=D0=B2=D0=BE=D0=B8=D0=BC=D0=B8=20=D0=B4=D0=B2?= =?UTF-8?q?=D0=BE=D0=B9=D0=BD=D0=B8=D0=BA=D0=B0=D0=BC=D0=B8=20(#1772)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Канон-ключ 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 --- ...n_strip_district_glued_to_house_number.sql | 77 +++++++++++++++++++ tradein-mvp/backend/tests/skip_allowlist.txt | 1 + .../backend/tests/test_house_dedup_merge.py | 77 +++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/311_canon_strip_district_glued_to_house_number.sql diff --git a/tradein-mvp/backend/data/sql/311_canon_strip_district_glued_to_house_number.sql b/tradein-mvp/backend/data/sql/311_canon_strip_district_glued_to_house_number.sql new file mode 100644 index 00000000..fca87ef5 --- /dev/null +++ b/tradein-mvp/backend/data/sql/311_canon_strip_district_glued_to_house_number.sql @@ -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; diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt index b1b98478..d8c050e4 100644 --- a/tradein-mvp/backend/tests/skip_allowlist.txt +++ b/tradein-mvp/backend/tests/skip_allowlist.txt @@ -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 diff --git a/tradein-mvp/backend/tests/test_house_dedup_merge.py b/tradein-mvp/backend/tests/test_house_dedup_merge.py index 86f697d3..0ab7a608 100644 --- a/tradein-mvp/backend/tests/test_house_dedup_merge.py +++ b/tradein-mvp/backend/tests/test_house_dedup_merge.py @@ -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):