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
Три находки эпика #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
114 lines
4.7 KiB
Python
114 lines
4.7 KiB
Python
"""SearchParams + SearchResponse — /api/v1/search (Phase 3.2)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Literal
|
||
|
||
from pydantic import BaseModel, Field, model_validator
|
||
|
||
SortKey = Literal["price_asc", "price_desc", "area_desc", "area_asc", "date_desc", "dist_asc"]
|
||
|
||
# Сегмент рынка (#1188, поверх canon-предиката #1186).
|
||
# NULL listing_segment (legacy вторичка до миграции 011) трактуется как 'vtorichka'.
|
||
SegmentKey = Literal["vtorichka", "novostroyki", "all"]
|
||
|
||
|
||
class SearchParams(BaseModel):
|
||
"""Фильтры поиска по listings_search_mv (master plan sec 9.1)."""
|
||
|
||
# --- Geo (radius search) ---
|
||
lat: float | None = Field(default=None, ge=-90.0, le=90.0)
|
||
lon: float | None = Field(default=None, ge=-180.0, le=180.0)
|
||
radius_m: int = Field(default=2000, ge=100, le=50000)
|
||
|
||
# --- Property filters ---
|
||
rooms: int | None = Field(default=None, ge=0, le=10)
|
||
rooms_in: list[int] | None = None
|
||
area_m2_min: float | None = Field(default=None, ge=0)
|
||
area_m2_max: float | None = Field(default=None, ge=0)
|
||
price_rub_min: int | None = Field(default=None, ge=0)
|
||
price_rub_max: int | None = Field(default=None, ge=0)
|
||
price_per_m2_max: int | None = Field(default=None, ge=0)
|
||
floor_min: int | None = Field(default=None, ge=1)
|
||
floor_max: int | None = Field(default=None, ge=1)
|
||
|
||
# --- House filters ---
|
||
year_built_min: int | None = Field(default=None, ge=1800, le=2100)
|
||
year_built_max: int | None = Field(default=None, ge=1800, le=2100)
|
||
house_class: list[str] | None = None
|
||
floors_total_min: int | None = Field(default=None, ge=1)
|
||
floors_total_max: int | None = Field(default=None, ge=1)
|
||
|
||
# --- Quality / cross-source ---
|
||
# has_kadastr снят (#2674): listings.cadastral_number пуст у всех 93 408 строк,
|
||
# фильтр мог вернуть только пустую выдачу. Лишний query-param FastAPI игнорирует,
|
||
# так что старые клиенты не ломаются.
|
||
sources: list[Literal["avito", "cian", "yandex_realty"]] | None = None
|
||
multi_source_only: bool = False
|
||
require_avito: bool = False
|
||
require_cian: bool = False
|
||
require_yandex: bool = False
|
||
|
||
# --- Text search ---
|
||
address_query: str | None = Field(default=None, max_length=200)
|
||
description_query: str | None = Field(default=None, max_length=200)
|
||
|
||
# --- Market segment (#1188) ---
|
||
segment: SegmentKey = Field(
|
||
default="vtorichka",
|
||
description=(
|
||
"Сегмент рынка для фильтрации listings. "
|
||
"`vtorichka` (по умолчанию, back-compat) — вторичка; "
|
||
"строки с listing_segment=NULL (legacy до миграции 011) "
|
||
"считаются вторичкой согласно canon-предикату #1186. "
|
||
"`novostroyki` — только первичка. "
|
||
"`all` — без фильтра по сегменту."
|
||
),
|
||
)
|
||
|
||
# --- Sort + pagination ---
|
||
sort: SortKey = "date_desc"
|
||
page: int = Field(default=1, ge=1, le=1000)
|
||
page_size: int = Field(default=50, ge=1, le=200)
|
||
|
||
@model_validator(mode="after")
|
||
def _validate_geo_pair(self) -> SearchParams:
|
||
if (self.lat is None) != (self.lon is None):
|
||
raise ValueError("lat and lon must be provided together")
|
||
if self.sort == "dist_asc" and self.lat is None:
|
||
raise ValueError("sort=dist_asc requires lat+lon")
|
||
if (
|
||
self.price_rub_min is not None
|
||
and self.price_rub_max is not None
|
||
and self.price_rub_min > self.price_rub_max
|
||
):
|
||
raise ValueError("price_rub_min > price_rub_max")
|
||
if (
|
||
self.area_m2_min is not None
|
||
and self.area_m2_max is not None
|
||
and self.area_m2_min > self.area_m2_max
|
||
):
|
||
raise ValueError("area_m2_min > area_m2_max")
|
||
if (
|
||
self.floor_min is not None
|
||
and self.floor_max is not None
|
||
and self.floor_min > self.floor_max
|
||
):
|
||
raise ValueError("floor_min > floor_max")
|
||
if (
|
||
self.year_built_min is not None
|
||
and self.year_built_max is not None
|
||
and self.year_built_min > self.year_built_max
|
||
):
|
||
raise ValueError("year_built_min > year_built_max")
|
||
if (
|
||
self.floors_total_min is not None
|
||
and self.floors_total_max is not None
|
||
and self.floors_total_min > self.floors_total_max
|
||
):
|
||
raise ValueError("floors_total_min > floors_total_max")
|
||
return self
|
||
|
||
@property
|
||
def offset(self) -> int:
|
||
return (self.page - 1) * self.page_size
|