From b105e0a5009a1167d3b8c33bc7be8775501ac99e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 29 Jun 2026 00:22:20 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/sale-share):=20=D0=B4=D0=B5=D0=B4?= =?UTF-8?q?=D1=83=D0=BF=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=BB=D0=B8=D1=81=D1=82=D0=B8=D0=BD=D0=B3=D0=BE=D0=B2=20+?= =?UTF-8?q?=20=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=B0=20=D0=B3=D0=BE=D0=BB?= =?UTF-8?q?=D1=8B=D1=85=20=D0=B0=D0=BB=D0=B8=D0=B0=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4 Карточка дома показывала cross-площадочные дубли (Искровцев 33: 2 квартиры = 4 строки avito/domklik/cian). build_listings_query теперь дедуплит по сигнатуре (rooms, round(area_m2), floor) — как v_building_sale_share мигр.148 — одна строка на квартиру, представитель = самый дешёвый, +колонка sources[] (площадки). Гео-фильтр ≤300м (мигр.150) сохранён. +BuildingListing.sources в схеме. P4a Мигр.151: DELETE 6338/21720 голых алиасов house_address_aliases (без номера дома) — сток, в который сваливались безномерные листинги (Tier-2b). Матчер уже захардненен (P1 #2082), голые не регенерируют. Алиасы — регенерируемый кэш, house_id_fk/house_sources не тронуты. ruff clean, pytest test_buildings_api 26 green. --- tradein-mvp/backend/app/schemas/buildings.py | 4 ++ .../backend/app/services/buildings_query.py | 50 +++++++++++++++---- .../sql/151_clean_bare_street_aliases.sql | 35 +++++++++++++ .../backend/tests/test_buildings_api.py | 27 +++++++++- 4 files changed, 104 insertions(+), 12 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/151_clean_bare_street_aliases.sql diff --git a/tradein-mvp/backend/app/schemas/buildings.py b/tradein-mvp/backend/app/schemas/buildings.py index 69f2a796..426c4d38 100644 --- a/tradein-mvp/backend/app/schemas/buildings.py +++ b/tradein-mvp/backend/app/schemas/buildings.py @@ -63,6 +63,10 @@ class BuildingListing(BaseModel): days_on_market: int | None = None listing_date: date | None = None address: str | None = None + # Площадки, где видна эта физическая квартира (дедуп кросс-площадочных дублей + # по сигнатуре (rooms, round(area_m2), floor) — мигр.148). Представитель строки — + # самый дешёвый листинг; sources перечисляет все источники той же квартиры. + sources: list[str] | None = None class HistogramBucket(BaseModel): diff --git a/tradein-mvp/backend/app/services/buildings_query.py b/tradein-mvp/backend/app/services/buildings_query.py index dd48d8fc..e39cea52 100644 --- a/tradein-mvp/backend/app/services/buildings_query.py +++ b/tradein-mvp/backend/app/services/buildings_query.py @@ -136,20 +136,48 @@ def build_sale_share_query( def build_listings_query(house_id: int) -> tuple[str, dict[str, object]]: - """Активные вторичные объявления одного дома (ORDER BY price_rub). + """Уникальные активные вторичные квартиры одного дома (дедуп по сигнатуре + (rooms, round(area_m2), floor) — та же, что в v_building_sale_share мигр.148: одна + физическая квартира на avito+cian+domclick = одна строка, не 2-4). Представитель — + самый дешёвый листинг; sources — все площадки, где квартира видна. Гео-фильтр ≤300м + (мигр.150) против чужих листингов. Листинги/дома без geom — КЕЕП. - Гео-фильтр (≤300м от geom дома) против mis-bucketing листингов в чужой дом — - тот же порог, что в v_building_sale_share (мигр.150). Листинги/дома без geom — КЕЕП. + DISTINCT ON-ключи (rooms, round(area_m2), floor) лидируют в ORDER BY, поэтому + представитель детерминирован (price_rub ASC NULLS LAST, id → берётся самый дешёвый). + round(area_m2), а не raw area_m2, используется и в DISTINCT ON, и в ORDER BY, и в + GROUP BY src — иначе сигнатура разъехалась бы с мигр.148. Внешний SELECT + пересортировывает уникальные квартиры по цене (контракт панели — дешёвые сверху). + Join-ключи src — IS NOT DISTINCT FROM (NULL-safe), чтобы листинги с NULL + rooms/area/floor не выпадали из дедупа. """ sql = ( - "SELECT l.id AS listing_id, l.source, l.source_url, l.price_rub, l.price_per_m2, " - "l.rooms, CAST(l.area_m2 AS double precision) AS area_m2, l.floor, l.total_floors, " - "l.days_on_market, l.listing_date, l.address " - "FROM listings l JOIN houses h ON h.id = l.house_id_fk " - "WHERE l.house_id_fk = CAST(:house_id AS bigint) " - "AND l.is_active AND l.listing_segment = 'vtorichka' " - "AND (l.geom IS NULL OR h.geom IS NULL OR ST_DistanceSphere(l.geom, h.geom) <= 300) " - "ORDER BY l.price_rub ASC NULLS LAST, l.id" + "WITH active AS (" + " SELECT l.id, l.source, l.source_url, l.price_rub, l.price_per_m2, l.rooms, " + " l.area_m2, l.floor, l.total_floors, l.days_on_market, l.listing_date, " + " l.address " + " FROM listings l JOIN houses h ON h.id = l.house_id_fk " + " WHERE l.house_id_fk = CAST(:house_id AS bigint) " + " AND l.is_active AND l.listing_segment = 'vtorichka' " + " AND (l.geom IS NULL OR h.geom IS NULL " + " OR ST_DistanceSphere(l.geom, h.geom) <= 300)" + "), src AS (" + " SELECT rooms, round(area_m2) AS sig_area, floor, " + " array_agg(DISTINCT source ORDER BY source) AS sources " + " FROM active GROUP BY rooms, round(area_m2), floor" + "), dedup AS (" + " SELECT DISTINCT ON (a.rooms, round(a.area_m2), a.floor) " + " a.id AS listing_id, a.source, a.source_url, a.price_rub, a.price_per_m2, " + " a.rooms, CAST(a.area_m2 AS double precision) AS area_m2, a.floor, " + " a.total_floors, a.days_on_market, a.listing_date, a.address, s.sources " + " FROM active a " + " JOIN src s ON s.rooms IS NOT DISTINCT FROM a.rooms " + " AND s.sig_area IS NOT DISTINCT FROM round(a.area_m2) " + " AND s.floor IS NOT DISTINCT FROM a.floor " + " ORDER BY a.rooms, round(a.area_m2), a.floor, a.price_rub ASC NULLS LAST, a.id" + ") " + "SELECT listing_id, source, source_url, price_rub, price_per_m2, rooms, area_m2, " + "floor, total_floors, days_on_market, listing_date, address, sources " + "FROM dedup ORDER BY price_rub ASC NULLS LAST, listing_id" ) return sql, {"house_id": house_id} diff --git a/tradein-mvp/backend/data/sql/151_clean_bare_street_aliases.sql b/tradein-mvp/backend/data/sql/151_clean_bare_street_aliases.sql new file mode 100644 index 00000000..e8bfc385 --- /dev/null +++ b/tradein-mvp/backend/data/sql/151_clean_bare_street_aliases.sql @@ -0,0 +1,35 @@ +-- 151_clean_bare_street_aliases.sql +-- +-- CONTEXT: house_address_aliases — кэш матчинга листинг→дом (normalized_address → house_id). +-- 6338 из 21720 (29%) алиасов — «голые улицы» БЕЗ номера дома (normalized_address вида +-- «екатеринбург улица мамина сибиряка»). Любой безномерный листинг (Яндекс-SERP часто без +-- номера дома) матчился в такой алиас (Tier-2b match в matching/houses.py) → сваливание +-- ЧУЖИХ листингов в один дом (прод: house 131237 «Мамина-Сибиряка 126» стянул 8 разных +-- Мамина-номеров + голую улицу → ложная доля). Матчер захардненен (P1, PR #2082): больше +-- НЕ создаёт и НЕ матчит голые алиасы. Эта миграция удаляет УЖЕ НАКОПЛЕННЫЙ сток. +-- +-- WHAT: DELETE алиасов без хвостового номера дома (последний токен начинается с цифры). +-- KEEP: «улица вайнера 66», «мамина сибиряка 126а», «улица 8 марта 5» (номер дома есть). +-- DELETE: «улица мамина сибиряка», «улица 8 марта» (цифра в НАЗВАНИИ улицы, не номер дома). +-- Регэксп идентичен Python-хелперу has_house_number ('(?:^|\s)\d\S*$', PR #2082). +-- +-- SAFETY / IDEMPOTENCY: house_address_aliases — РЕГЕНЕРИРУЕМЫЙ кэш матчинга; матчер +-- пересоздаёт корректные (номерные) алиасы на следующем матче. DELETE НЕ трогает +-- listings.house_id_fk, house_sources, houses — только кэш-таблицу алиасов. Повторный +-- прогон удалит 0 (предикат стабилен). Таблица ~21.7k строк → DELETE мгновенный, без +-- длинных локов. Деплой-раннер гонит файл через psql -v ON_ERROR_STOP=on БЕЗ +-- --single-transaction → транзакцию открывает САМ файл (BEGIN/COMMIT ниже). +-- +-- DEPENDENCIES: PR #2082 (P1-гард в matching/houses.py уже в проде — голые алиасы не +-- регенерируют после удаления). house_address_aliases.normalized_address (мигр. cross-source +-- matching). NB нумерации: последний занятый = 150; следующий sequential = 151 +-- (`ls tradein-mvp/backend/data/sql | grep '^15'` + открытые PR — дубля basename нет). +-- +-- Deploy order: после 150_sale_share_listing_geo_filter.sql. + +BEGIN; + +DELETE FROM house_address_aliases +WHERE normalized_address !~ '(^|[[:space:]])[0-9][^[:space:]]*$'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_buildings_api.py b/tradein-mvp/backend/tests/test_buildings_api.py index 9c58ee81..0eccf553 100644 --- a/tradein-mvp/backend/tests/test_buildings_api.py +++ b/tradein-mvp/backend/tests/test_buildings_api.py @@ -128,13 +128,38 @@ def test_listings_query_shape(): assert "house_id_fk = CAST(:house_id AS bigint)" in sql assert "listing_segment = 'vtorichka'" in sql assert "is_active" in sql - assert "ORDER BY l.price_rub" in sql assert "JOIN houses" in sql assert "ST_DistanceSphere(l.geom, h.geom) <= 300" in sql assert "id AS listing_id" in sql assert args["house_id"] == 42 +def test_listings_query_dedups_by_signature(): + """Дедуп кросс-площадочных дублей: одна строка на сигнатуру (rooms, round(area_m2), + floor) — та же, что в v_building_sale_share мигр.148.""" + sql, _ = build_listings_query(42) + # DISTINCT ON по сигнатуре; round(area_m2), не raw area_m2 — как в мигр.148. + assert "DISTINCT ON (a.rooms, round(a.area_m2), a.floor)" in sql + # представитель — самый дешёвый: price_rub ASC NULLS LAST в лидирующем ORDER BY, + # с теми же ведущими ключами, что и DISTINCT ON (требование DISTINCT ON). + assert "ORDER BY a.rooms, round(a.area_m2), a.floor, a.price_rub ASC NULLS LAST, a.id" in sql + # массив площадок, где видна квартира → колонка sources. + assert "array_agg(DISTINCT source ORDER BY source) AS sources" in sql + assert "s.sources" in sql + # NULL-safe join по сигнатуре, чтобы NULL-компоненты не выпадали из дедупа. + assert "s.sig_area IS NOT DISTINCT FROM round(a.area_m2)" in sql + # внешняя сортировка панели — по цене (дешёвые сверху). + assert "ORDER BY price_rub ASC NULLS LAST, listing_id" in sql + + +def test_listings_query_no_double_colon_cast(): + """psycopg v3: ни одного `:name::type` в дедуп-запросе — только CAST(...).""" + import re + + sql, _ = build_listings_query(42) + assert re.search(r":[a-z_]+::[a-z]", sql) is None + + def test_summary_scalars_query_shape(): sql = build_summary_scalars_query() assert "total_secondary_buildings" in sql -- 2.45.3