gendesign/tradein-mvp/backend/app/schemas/search.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

114 lines
4.7 KiB
Python
Raw Permalink 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.

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