fix(tradein/sale-share): дедуп карточки листингов + чистка голых алиасов
#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:
parent
ec4c7e9e6a
commit
b105e0a500
4 changed files with 104 additions and 12 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue