gendesign/tradein-mvp/backend/app/services/search_query.py
bot-backend 3fd6550a16
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
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 2m54s
fix(tradein/matching): честность тиров сопоставления домов (#2674)
Три находки эпика #2674 про верхние тиры матчинга домов. Замеры — прод
tradein-postgres, 2026-08-05/06.

Кадастр от площадок не приходит вообще. listings.cadastral_number (кадастр
КВАРТИРЫ) — 0 из 93 408; единственный писатель, парсер Циана, читает
offer["cadastralNumber"], которого в ответе нет. Все 28 504 заполненных
building_cadastral_number на 100% пришли из локального гео-зеркала ЕГРН
(tasks/cadastral_geo_match.py, KNN <=50 м) — проверено джойном к
cad_buildings_local. Поэтому снят фильтр поиска has_kadastr: предикат
`cadastral_number IS NOT NULL` мог вернуть только пустую выдачу. Колонка и
писатель оставлены — заработают сами, если площадка начнёт отдавать кадастр.

Tier 0 cadastr_exact оставлен, но не подключён к гео-кадастру. Он достижим по
построению (ScrapedLot -> адаптер -> матчер), просто данных нет; подать туда
KNN-заполнение НЕЛЬЗЯ: как ключ здания оно не инъективно — 656 из 3 260
значений накрывают >1 здание ГАР (20.1%), 751 из 2 864 зданий получают >1
значение (26.2%). Это был бы over-merge с confidence 1.0. Заодно исправлено
ложное утверждение в шапке cadastral_geo_match.py, будто Tier 0 трактует эту
колонку как подсказку.

Tier 0.5 fias_exact удалён из match_or_create_house. Параметра house_fias_id
не было ни в Protocol scraper_kit.contracts.HouseMatcher, ни в
RealMatcherAdapter, ни у двух прямых вызывающих — передать его было некому.
В match_house_readonly тир оставлен: у estimate-пути источник ФИАС есть
(payload.target_fias_id / DaData).

Что чинит сопоставление на самом деле: ключ идентичности в house_dedup_merge
расширен с house_fias_id до COALESCE(house_fias_id, gar_house_guid). Это один
и тот же UUID здания в ГАР (3 666 совпадений из 3 667 домов, где заполнены оба),
но заполняют его разные источники, и половина в проход не входила. Read-only
прогон отрендеренного mapping-SQL на проде: старый ключ — 0 пар, новый — 781
(8.3% таблицы houses, 6 389 объявлений на них). Канон-проход эти дома узнаёт
(900 пар из 919 имеют один канон-адрес), но блокирует гео-стражем: 356 пар
с NULL geom, 457 дальше 250 м (максимум 5 065 км — битый геокод). Ровно
аргумент #2187: общий UUID здания старше близости.

Качество сопоставления сейчас: 0 из 49 502 строк house_sources сматчены
верхними тирами; fingerprint 58.97%, new 22.65%, geo_proximity 18.36%.

Тесты: новый tests/test_matching_tier_reachability_2674.py сверяет параметры
матчера с границей вызова (Protocol + адаптер) — ловит класс «ветка есть,
передать некому», который обычный тест не видит, потому что зовёт функцию
напрямую. Удалены два теста мёртвого fias-тира: они были зелёными ровно
потому, что обходили границу вызова.

Refs #2674
2026-08-06 04:37:12 +05:00

158 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""SQL builder for /api/v1/search — parameterized, psycopg v3 (CAST(:x AS type))."""
from __future__ import annotations
from app.schemas.search import SearchParams
_SORT_SQL: dict[str, str] = {
"price_asc": "price_rub ASC NULLS LAST",
"price_desc": "price_rub DESC NULLS LAST",
"area_desc": "total_area DESC NULLS LAST",
"area_asc": "total_area ASC NULLS LAST",
"date_desc": "scraped_at DESC NULLS LAST",
"dist_asc": (
"ST_Distance(geom::geography, "
"ST_MakePoint(CAST(:lon AS double precision), "
"CAST(:lat AS double precision))::geography) ASC"
),
}
# Сегмент рынка (#1188). listings_search_mv не несёт колонку listing_segment,
# поэтому фильтруем через подзапрос к базовой таблице listings, переиспользуя
# canon-предикат #1186: NULL = legacy вторичка до миграции 011.
_VTORICHKA_GUARD = "(listing_segment IS NULL OR listing_segment = 'vtorichka')"
_SEGMENT_SQL: dict[str, str | None] = {
"vtorichka": (f"listing_id IN (SELECT id FROM listings WHERE {_VTORICHKA_GUARD})"),
"novostroyki": (
"listing_id IN (SELECT id FROM listings WHERE listing_segment = 'novostroyki')"
),
"all": None,
}
def build_search_query(params: SearchParams) -> tuple[str, dict[str, object]]:
"""Возвращает (sql, args) для SELECT из listings_search_mv."""
where: list[str] = ["1=1"]
args: dict[str, object] = {}
if params.lat is not None and params.lon is not None:
where.append(
"ST_DWithin(geom::geography, "
"ST_MakePoint(CAST(:lon AS double precision), "
"CAST(:lat AS double precision))::geography, "
"CAST(:radius_m AS integer))"
)
args["lat"] = params.lat
args["lon"] = params.lon
args["radius_m"] = params.radius_m
if params.rooms is not None:
where.append("rooms = CAST(:rooms AS integer)")
args["rooms"] = params.rooms
if params.rooms_in:
where.append("rooms = ANY(CAST(:rooms_in AS integer[]))")
args["rooms_in"] = params.rooms_in
if params.area_m2_min is not None:
where.append("total_area >= CAST(:area_min AS double precision)")
args["area_min"] = params.area_m2_min
if params.area_m2_max is not None:
where.append("total_area <= CAST(:area_max AS double precision)")
args["area_max"] = params.area_m2_max
if params.price_rub_min is not None:
where.append("price_rub >= CAST(:price_min AS bigint)")
args["price_min"] = params.price_rub_min
if params.price_rub_max is not None:
where.append("price_rub <= CAST(:price_max AS bigint)")
args["price_max"] = params.price_rub_max
if params.price_per_m2_max is not None:
where.append("price_per_m2 <= CAST(:ppm2_max AS bigint)")
args["ppm2_max"] = params.price_per_m2_max
if params.floor_min is not None:
where.append("floor >= CAST(:floor_min AS integer)")
args["floor_min"] = params.floor_min
if params.floor_max is not None:
where.append("floor <= CAST(:floor_max AS integer)")
args["floor_max"] = params.floor_max
if params.year_built_min is not None:
where.append("year_built >= CAST(:yb_min AS integer)")
args["yb_min"] = params.year_built_min
if params.year_built_max is not None:
where.append("year_built <= CAST(:yb_max AS integer)")
args["yb_max"] = params.year_built_max
if params.house_class:
where.append("house_class = ANY(CAST(:hclass AS text[]))")
args["hclass"] = params.house_class
if params.floors_total_min is not None:
where.append("total_floors >= CAST(:fl_total_min AS integer)")
args["fl_total_min"] = params.floors_total_min
if params.floors_total_max is not None:
where.append("total_floors <= CAST(:fl_total_max AS integer)")
args["fl_total_max"] = params.floors_total_max
# Фильтр has_kadastr удалён (#2674): `listings.cadastral_number` (кадастр КВАРТИРЫ)
# пуст у всех 93 408 объявлений — площадки его не отдают (единственный писатель,
# парсер Циана, читает offer["cadastralNumber"], которого в ответе нет). Предикат
# `cadastral_number IS NOT NULL` мог вернуть только пустую выдачу, т.е. обещал
# качество данных, которого нет. Колонка и её писатель оставлены: если площадка
# начнёт отдавать кадастр, заполнение заработает само — тогда и вернём фильтр.
segment_clause = _SEGMENT_SQL[params.segment]
if segment_clause is not None:
where.append(segment_clause)
if params.sources:
where.append("sources && CAST(:sources AS text[])")
args["sources"] = params.sources
if params.multi_source_only:
where.append("source_count >= 2")
if params.require_avito:
where.append("has_avito = true")
if params.require_cian:
where.append("has_cian = true")
if params.require_yandex:
where.append("has_yandex = true")
if params.address_query:
where.append("address ILIKE CAST(:addr_like AS text)")
args["addr_like"] = f"%{params.address_query}%"
if params.description_query:
where.append("tsv @@ plainto_tsquery('russian', CAST(:descq AS text))")
args["descq"] = params.description_query
where_sql = " AND ".join(where)
order_sql = _SORT_SQL[params.sort]
sql = (
"SELECT listing_id, source, source_url, address, lat, lng, "
"rooms, total_area, floor, total_floors, price_rub, price_per_m2, "
"cadastral_number, scraped_at, "
"house_id, year_built, house_class, developer_name, "
"house_rating, house_ratings_count, "
"source_count, sources, has_avito, has_cian, has_yandex, "
"house_median_ppm2, district "
"FROM listings_search_mv "
f"WHERE {where_sql} "
f"ORDER BY {order_sql} "
"LIMIT CAST(:limit AS integer) OFFSET CAST(:offset AS integer)"
)
args["limit"] = params.page_size
args["offset"] = params.offset
return sql, args
def build_count_query(params: SearchParams) -> tuple[str, dict[str, object]]:
"""COUNT(*) — отдельный запрос для total."""
sql, args = build_search_query(params)
where_start = sql.find("WHERE ")
order_start = sql.find(" ORDER BY ")
where_clause = sql[where_start:order_start]
count_args = {k: v for k, v in args.items() if k not in ("limit", "offset")}
count_sql = f"SELECT count(*) AS total FROM listings_search_mv {where_clause}"
return count_sql, count_args