fix(tradein/sale-share): дедуп карточки листингов + чистка голых алиасов
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped

#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.
This commit is contained in:
bot-backend 2026-06-29 00:22:20 +03:00
parent ec4c7e9e6a
commit b105e0a500
4 changed files with 104 additions and 12 deletions

View file

@ -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):

View file

@ -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 "
"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) "
"ORDER BY l.price_rub ASC NULLS LAST, l.id"
" 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}

View file

@ -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;

View file

@ -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