fix(tradein/coverage): sync cohort with paid estimator, honest age medians

Independent review found two MAJOR defects in POST /api/v1/trade-in/coverage:

MAJOR-1: the probe cohort WHERE clause was missing three predicates present
in estimator._COMMON_WHERE / Tier W (novostroyki guard, geo_precision !=
'city', price_rub > 0) — the free probe could answer "ok" at points where
the paid estimator's own 1000m radius tier sees zero real analogs. Prod
example: 56.868904/60.837955, 2 rooms, 50 m2 gave n_listings=22/status=ok
while the estimator's cohort at the same radius was 0 (all 54 rows were
novostroyki). Added the three predicates verbatim from estimator.py, plus
both a static SQL-text regression test and a real-Postgres integration test
(skip_allowlist.txt, same _live_session() pattern as test_gar_flats_loader)
that inserts novostroyka/geo_precision=city/price=0 rows and asserts they
are not counted.

MAJOR-2: median_listing_age_days was computed from days_on_market, which on
prod is populated almost exclusively by one source (yandex) — thin cohorts
produced a "median" over 1-2 listings. Added n_with_age to the response
(honest count of listings the median is based on); median is now null below
COVERAGE_MIN_AGE_SAMPLES=5, and values above COVERAGE_MAX_AGE_DAYS=365 (near
-certainly dead listings, per prod: 15% of fresh yandex rows exceed 365d,
max 4261d) are excluded as outliers before the percentile is computed.

MINOR: city_hint was trusted at face value and echoed back verbatim — a
client could pass city_hint="Екатеринбург" with coordinates in Серов and get
threshold=8/status=ok. _resolve_coverage_city now prioritizes the SQL
cohort's mode city (ground truth) over the client hint, falling back to hint
only when the cohort is empty (where status is forced not_covered anyway).
Unmatched cities no longer echo the raw client string in the city field.
This commit is contained in:
bot-backend 2026-08-15 20:47:23 +03:00
parent d0105470f4
commit 3e9af2fdef
4 changed files with 338 additions and 36 deletions

View file

@ -2564,6 +2564,17 @@ COVERAGE_RADIUS_M = 1000
COVERAGE_AREA_TOLERANCE = 0.15 # ±15% площади
COVERAGE_FRESH_DAYS = 14 # объявления не старше 14 дней (тот же канон, что LISTINGS_FRESH_DAYS)
# MAJOR-2 (независимый ревью #2894): days_on_market на проде заполнена практически
# только у yandex (avito/cian/domklik — 0 заполнено) — возраст известен у меньшинства
# когорты, и на тонких когортах "медиана" считалась по 1-2 объявлениям. Ниже порога
# n_with_age медиану не отдаём (null) — не продуктовое решение, а честность при
# заведомо шумной статистике по единичным точкам.
COVERAGE_MIN_AGE_SAMPLES = 5
# 15% свежих yandex-строк имеют days_on_market > 365 (максимум 4261) — это почти
# наверняка мёртвое/забытое объявление, которое никто не снял с публикации, а не
# сигнал о реальном времени экспозиции рынка. Отбрасываем как выброс из медианы.
COVERAGE_MAX_AGE_DAYS = 365
# Списки городов и пороги — константа РЯДОМ С РУЧКОЙ (issue #2894 требование), не в БД.
COVERAGE_GREEN_CITIES = ("Екатеринбург", "Верхняя Пышма", "Берёзовский", "Среднеуральск")
COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Уральский", "Первоуральск", "Ревда")
@ -2585,18 +2596,24 @@ _COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
def _resolve_coverage_city(city_hint: str | None, cohort_city: str | None) -> tuple[str, int, bool]:
"""Резолвит (display_city, threshold, is_supported) для пробы покрытия.
Приоритет: явный city_hint фронта (тот же автокомплит, что заполняет
TradeInEstimateInput.city_hint) > мода city найденной SQL-когорты
(best-effort фолбэк, когда фронт его не передал). Город вне зелёного/
жёлтого списка threshold=0, is_supported=False вызывающий обязан
трактовать это как not_covered независимо от n_listings.
MINOR fix (независимый ревью #2894): city_hint — это НЕ проверенный вход,
клиент им управляет напрямую (lat/lon в Серове + city_hint='Екатеринбург'
раньше давал threshold=8 и status='ok' клиент выбирал себе порог). Источник
истины мода city найденной SQL-когорты (то, что реально лежит в БД рядом с
переданными lat/lon); city_hint используется ТОЛЬКО как фолбэк, когда когорта
пуста (cohort_city is None) в этом случае n_listings тоже 0, и caller всё
равно форсирует status="not_covered" независимо от threshold/supported, так
что подмена клиентом порога здесь не даёт эффекта.
Эхо произвольной клиентской строки в поле city убрано: candidate, не нашедший
совпадения в зелёном/жёлтом списке, отдаётся как "" (не supported), а не как
сырой ввод.
"""
candidate = (city_hint or cohort_city or "").strip()
candidate = (cohort_city or city_hint or "").strip()
match = _COVERAGE_CITY_THRESHOLDS.get(_fold_city(candidate)) if candidate else None
if match is not None:
display, threshold = match
return display, threshold, True
return candidate, 0, False
return "", 0, False
@router.post("/coverage", response_model=CoverageProbeResponse)
@ -2613,8 +2630,23 @@ def coverage_probe(
в module-level import graph): ST_DWithin 1000м, rooms точное совпадение,
area ±15%, scraped_at не старше 14 дней, is_active.
MAJOR-1 fix (независимый ревью #2894): когорта пробы обязана быть
ПОДМНОЖЕСТВОМ когорты платного эстиматора, не шире её иначе проба честно
отвечает "ok" там, где платный расчёт увидит 0. Три предиката ниже тот же
канон, что estimator._COMMON_WHERE (app/services/estimator.py:5441/5460) и
inline-копия Tier W (estimator.py:5910/5916/5932, radius-тир, откуда реально
берутся аналоги на 1000 м): guard новостроек, geo_precision != 'city'
(#769 Part E — city-centroid листинги без реального адреса), price_rub > 0.
В ответе НЕТ ни одной цены см. CoverageProbeResponse docstring.
MAJOR-2 (независимый ревью #2894): days_on_market на проде фактически
заполнена только у ОДНОГО источника (yandex) это ограничение данных, а
не продуктовое решение. n_with_age в ответе честно считает, по скольким
объявлениям взята медиана; ниже COVERAGE_MIN_AGE_SAMPLES null (см. поле
в ответе). Значения > COVERAGE_MAX_AGE_DAYS (почти наверняка мёртвое
объявление) в расчёт медианы не берутся.
#oblast (2026-08): house_placement_history.exposure_days — реальная (не
цензурированная) экспозиция history-строк НЕ используется здесь: это
house-level архив (join по house_id, не привязан к текущей radius/rooms/
@ -2648,11 +2680,27 @@ def coverage_probe(
AND ST_DWithin(
geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius
)
-- MAJOR-1: sync с estimator._COMMON_WHERE (5441) / Tier W (5916)
AND price_rub > 0
-- MAJOR-1: sync с estimator._COMMON_WHERE (5460) / Tier W (5932)
-- guard новостроек, NULL = legacy вторичка до м.011
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
-- MAJOR-1: sync с estimator Tier W (5910/5945-5948, #769 Part E) —
-- исключает city-centroid листинги без реального адреса;
-- IS DISTINCT FROM пропускает NULL (неизвестная точность)
AND (geo_precision IS DISTINCT FROM 'city')
)
SELECT
count(*) AS n_listings,
count(*) FILTER (
WHERE days_on_market IS NOT NULL
AND days_on_market <= :max_age_days
) AS n_with_age,
percentile_cont(0.5) WITHIN GROUP (ORDER BY days_on_market)
AS median_age_days,
FILTER (
WHERE days_on_market IS NOT NULL
AND days_on_market <= :max_age_days
) AS median_age_days,
mode() WITHIN GROUP (ORDER BY city)
FILTER (WHERE city IS NOT NULL) AS cohort_city
FROM base
@ -2669,6 +2717,7 @@ def coverage_probe(
"lon": payload.lon,
"radius": COVERAGE_RADIUS_M,
"max_per_addr": MAX_ANALOGS_PER_ADDRESS,
"max_age_days": COVERAGE_MAX_AGE_DAYS,
},
)
.mappings()
@ -2676,9 +2725,12 @@ def coverage_probe(
)
n_listings = int(row["n_listings"]) if row else 0
n_with_age = int(row["n_with_age"]) if row and row["n_with_age"] is not None else 0
median_age = (
round(row["median_age_days"])
if row is not None and row["median_age_days"] is not None
if row is not None
and row["median_age_days"] is not None
and n_with_age >= COVERAGE_MIN_AGE_SAMPLES
else None
)
cohort_city = row["cohort_city"] if row else None
@ -2693,18 +2745,20 @@ def coverage_probe(
status = "thin"
logger.info(
"coverage probe rooms=%d area=%.1f city=%r status=%s n=%d",
"coverage probe rooms=%d area=%.1f city=%r status=%s n=%d n_with_age=%d",
payload.rooms,
payload.area_m2,
city,
status,
n_listings,
n_with_age,
)
return CoverageProbeResponse(
status=status,
n_listings=n_listings,
median_listing_age_days=median_age,
n_with_age=n_with_age,
radius_m=COVERAGE_RADIUS_M,
city=city,
threshold=threshold,

View file

@ -792,6 +792,16 @@ class CoverageProbeResponse(BaseModel):
(days_on_market на текущий момент), а НЕ срок до продажи. Цензурированная
выборка (активные объявления ещё висят) всегда завышена относительно
реального времени экспозиции проданных не путать со «сроком продажи».
ОГРАНИЧЕНИЕ ДАННЫХ (не продуктовое решение, см. coverage_probe docstring):
days_on_market на проде заполнена практически только у источника yandex
возраст известен у меньшинства строк когорты. n_with_age ниже честный
счётчик, по скольким объявлениям посчитана медиана; при n_with_age < порога
(COVERAGE_MIN_AGE_SAMPLES) median_listing_age_days принудительно null.
n_with_age сколько объявлений когорты реально имеют известный
(non-null, не-выброс) days_on_market и вошли в расчёт медианы. Фронт
обязан иметь возможность не показывать median_listing_age_days при
маленьком n_with_age цифра "медиана" по 1-2 объявлениям не медиана.
threshold n, начиная с которого статус переходит в "ok" для резолвленного
города; 0, если город не входит ни в один список (порог неприменим
@ -801,6 +811,7 @@ class CoverageProbeResponse(BaseModel):
status: Literal["ok", "thin", "not_covered"]
n_listings: int
median_listing_age_days: int | None
n_with_age: int
radius_m: int
city: str
threshold: int

View file

@ -69,3 +69,9 @@ tests/test_2764_ban_kind_no_default.py::test_real_default_ban_kind_survives_the_
tests/test_house_imv_retry_stuck.py::test_explicit_only_status_still_takes_exhausted_houses
tests/test_house_imv_retry_stuck.py::test_stuck_transient_house_returns_to_the_queue_by_itself
tests/test_house_imv_retry_stuck.py::test_transient_attempts_counter_only_counts_transient
# MAJOR-1 fix, coverage probe (#2894, независимый ревью) — тот же `_live_session()`.
# Проверяет, что novostroyki-строка / geo_precision='city'-строка / price_rub=0-строка
# физически не попадают в когорту (не только SQL-текст, который проверяется отдельным
# статическим тестом test_cohort_sql_excludes_* в этом же файле, идущим на обоих лэйнах).
tests/test_coverage_probe_endpoint.py::test_major1_cohort_excludes_novostroyki_and_city_precision_live

View file

@ -6,8 +6,13 @@
- пороги ok/thin/not_covered для зелёных/жёлтых/неподдерживаемых городов
- пустая когорта (n=0) not_covered даже в поддерживаемом городе
- в ответе НЕТ ни одного price-подобного поля (падающий тест на регресс схемы)
- city_hint приоритетнее моды city из когорты
- median_listing_age_days median(days_on_market), None при пустой когорте
- MAJOR-1 (независимый ревью #2894): когорта пробы — sync с
estimator._COMMON_WHERE / Tier W (novostroyki guard, geo_precision != 'city',
price_rub > 0), не шире когорты платного эстиматора
- MAJOR-2: median_listing_age_days честно null при тонкой n_with_age выборке,
выбросы (> COVERAGE_MAX_AGE_DAYS) не тянут медиану
- MINOR: когортный город (мода) побеждает city_hint при расхождении клиент
не управляет порогом; неизвестный город не эхуется сырой строкой
"""
from __future__ import annotations
@ -49,6 +54,25 @@ def trade_in_app() -> FastAPI:
return application
def _row(
n_listings: int,
median_age_days: float | None,
cohort_city: str | None,
n_with_age: int | None = None,
) -> dict:
"""Строка, которую coverage_probe читает через db.execute(...).mappings().fetchone().
n_with_age по умолчанию = n_listings, если не задан явно (большинство старых
тестов не проверяют MAJOR-2 отдельно сохраняем их поведение).
"""
return {
"n_listings": n_listings,
"median_age_days": median_age_days,
"cohort_city": cohort_city,
"n_with_age": n_with_age if n_with_age is not None else n_listings,
}
def _db_mock_returning(row: dict | None) -> MagicMock:
"""DB session mock — coverage_probe reads db.execute(...).mappings().fetchone()."""
db = MagicMock()
@ -85,9 +109,7 @@ def test_coverage_response_has_no_price_fields(trade_in_app: FastAPI) -> None:
def test_coverage_actual_response_has_no_price_fields(trade_in_app: FastAPI) -> None:
"""Same guard but on a live serialized response (belt-and-suspenders)."""
db = _db_mock_returning(
{"n_listings": 10, "median_age_days": 21.0, "cohort_city": "Екатеринбург"}
)
db = _db_mock_returning(_row(10, 21.0, "Екатеринбург"))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
@ -103,9 +125,7 @@ def test_coverage_actual_response_has_no_price_fields(trade_in_app: FastAPI) ->
def test_green_city_ok_at_threshold(trade_in_app: FastAPI) -> None:
"""Екатеринбург (зелёный, порог 8) — n=8 ровно на границе → ok."""
db = _db_mock_returning(
{"n_listings": 8, "median_age_days": 15.0, "cohort_city": "Екатеринбург"}
)
db = _db_mock_returning(_row(8, 15.0, "Екатеринбург"))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
@ -118,13 +138,12 @@ def test_green_city_ok_at_threshold(trade_in_app: FastAPI) -> None:
assert data["city"] == "Екатеринбург"
assert data["radius_m"] == 1000
assert data["median_listing_age_days"] == 15
assert data["n_with_age"] == 8
def test_green_city_thin_below_threshold(trade_in_app: FastAPI) -> None:
"""Екатеринбург, n=7 (< порог 8) → thin, не ok и не not_covered."""
db = _db_mock_returning(
{"n_listings": 7, "median_age_days": 10.0, "cohort_city": "Екатеринбург"}
)
db = _db_mock_returning(_row(7, 10.0, "Екатеринбург"))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
@ -139,10 +158,8 @@ def test_green_city_thin_below_threshold(trade_in_app: FastAPI) -> None:
def test_yellow_city_ok_at_threshold(trade_in_app: FastAPI) -> None:
"""Нижний Тагил (жёлтый, порог 12) — n=12 → ok."""
db = _db_mock_returning(
{"n_listings": 12, "median_age_days": 30.0, "cohort_city": "Нижний Тагил"}
)
"""Нижний Тагил (жёлтый, порог 12) — n=12 → ok. cohort_city совпадает с hint."""
db = _db_mock_returning(_row(12, 30.0, "Нижний Тагил"))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
@ -157,8 +174,8 @@ def test_yellow_city_ok_at_threshold(trade_in_app: FastAPI) -> None:
def test_yellow_city_thin_below_threshold(trade_in_app: FastAPI) -> None:
"""Ревда, n=11 (< порог 12) → thin."""
db = _db_mock_returning({"n_listings": 11, "median_age_days": 40.0, "cohort_city": None})
"""Ревда, n=11 (< порог 12) → thin. Когорта пуста по городу → используем hint."""
db = _db_mock_returning(_row(11, 40.0, None))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
@ -173,7 +190,7 @@ def test_yellow_city_thin_below_threshold(trade_in_app: FastAPI) -> None:
def test_unsupported_city_not_covered_even_with_high_n(trade_in_app: FastAPI) -> None:
"""Город вне списков → not_covered независимо от n_listings (даже n=500)."""
db = _db_mock_returning({"n_listings": 500, "median_age_days": 5.0, "cohort_city": "Серов"})
db = _db_mock_returning(_row(500, 5.0, "Серов"))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
@ -182,6 +199,7 @@ def test_unsupported_city_not_covered_even_with_high_n(trade_in_app: FastAPI) ->
assert data["status"] == "not_covered"
assert data["threshold"] == 0
assert data["n_listings"] == 500 # честно отдаём счётчик, статус его игнорирует
assert data["city"] == "" # MINOR: неизвестный город не эхуется сырой строкой
# ── Empty cohort ──────────────────────────────────────────────────────────────
@ -189,7 +207,7 @@ def test_unsupported_city_not_covered_even_with_high_n(trade_in_app: FastAPI) ->
def test_empty_cohort_supported_city_not_covered(trade_in_app: FastAPI) -> None:
"""n=0 в поддерживаемом (зелёном) городе → not_covered, не thin — честнее."""
db = _db_mock_returning({"n_listings": 0, "median_age_days": None, "cohort_city": None})
db = _db_mock_returning(_row(0, None, None, n_with_age=0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
@ -200,6 +218,7 @@ def test_empty_cohort_supported_city_not_covered(trade_in_app: FastAPI) -> None:
assert data["status"] == "not_covered"
assert data["n_listings"] == 0
assert data["median_listing_age_days"] is None
assert data["n_with_age"] == 0
def test_empty_cohort_no_row_at_all(trade_in_app: FastAPI) -> None:
@ -215,14 +234,37 @@ def test_empty_cohort_no_row_at_all(trade_in_app: FastAPI) -> None:
assert data["status"] == "not_covered"
assert data["n_listings"] == 0
assert data["median_listing_age_days"] is None
assert data["n_with_age"] == 0
# ── city_hint priority over cohort mode ─────────────────────────────────────────
# ── MINOR: cohort mode (реальные данные из БД) побеждает city_hint ──────────────
def test_city_hint_overrides_cohort_mode(trade_in_app: FastAPI) -> None:
"""city_hint (фронт) побеждает cohort_city (SQL mode) при определении города."""
db = _db_mock_returning({"n_listings": 9, "median_age_days": 12.0, "cohort_city": "Серов"})
def test_cohort_mode_overrides_city_hint_on_mismatch(trade_in_app: FastAPI) -> None:
"""lat/lon в Серове + city_hint='Екатеринбург' — клиент не управляет порогом.
Когорта реально нашлась в Серове (cohort_city="Серов", город вне списков)
ответ обязан игнорировать спуфленный hint и не выдавать зелёный threshold=8.
Регресс на прод-инцидент из независимого ревью #2894.
"""
db = _db_mock_returning(_row(9, 12.0, "Серов"))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post(
"/api/v1/trade-in/coverage",
json={**_BASE_PAYLOAD, "city_hint": "Екатеринбург"},
)
data = resp.json()
assert data["city"] != "Екатеринбург"
assert data["status"] == "not_covered" # Серов вне зелёного/жёлтого списка
assert data["threshold"] == 0
def test_city_hint_used_only_as_fallback_for_empty_cohort(trade_in_app: FastAPI) -> None:
"""Когорта пуста (cohort_city=None) — hint используется как фолбэк для display,
но status всё равно not_covered (n_listings=0), так что подмена без эффекта."""
db = _db_mock_returning(_row(0, None, None, n_with_age=0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
@ -232,12 +274,12 @@ def test_city_hint_overrides_cohort_mode(trade_in_app: FastAPI) -> None:
)
data = resp.json()
assert data["city"] == "Екатеринбург"
assert data["status"] == "ok" # n=9 >= 8 (зелёный порог), не серовский not_covered
assert data["status"] == "not_covered"
def test_yo_fold_city_hint_matches(trade_in_app: FastAPI) -> None:
"""«Березовский» без ё должен резолвиться в тот же зелёный порог, что «Берёзовский»."""
db = _db_mock_returning({"n_listings": 8, "median_age_days": 5.0, "cohort_city": None})
db = _db_mock_returning(_row(8, 5.0, None, n_with_age=8))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
@ -250,12 +292,64 @@ def test_yo_fold_city_hint_matches(trade_in_app: FastAPI) -> None:
assert data["threshold"] == 8
# ── MAJOR-2: median age — n_with_age threshold + outlier clamp ─────────────────
def test_median_age_null_below_min_age_samples(trade_in_app: FastAPI) -> None:
"""n_with_age=2 (< COVERAGE_MIN_AGE_SAMPLES=5) → median_listing_age_days null,
даже если SQL посчитал percentile "медиана" по 1-2 объявлениям не медиана."""
from app.api.v1.trade_in import COVERAGE_MIN_AGE_SAMPLES
assert COVERAGE_MIN_AGE_SAMPLES == 5
db = _db_mock_returning(_row(20, 40.0, "Екатеринбург", n_with_age=2))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
data = resp.json()
assert data["n_listings"] == 20 # когорта покрытия не урезается возрастным фильтром
assert data["n_with_age"] == 2
assert data["median_listing_age_days"] is None
def test_median_age_present_at_min_age_samples_threshold(trade_in_app: FastAPI) -> None:
"""n_with_age=5 (== порог) → median_listing_age_days отдаётся."""
db = _db_mock_returning(_row(20, 40.0, "Екатеринбург", n_with_age=5))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
data = resp.json()
assert data["n_with_age"] == 5
assert data["median_listing_age_days"] == 40
def test_max_age_outlier_days_passed_to_sql(trade_in_app: FastAPI) -> None:
"""COVERAGE_MAX_AGE_DAYS=365 передаётся в SQL как параметр — выбросы (мёртвые
объявления) отсекаются percentile_cont FILTER на стороне БД, не в Python."""
from app.api.v1.trade_in import COVERAGE_MAX_AGE_DAYS
assert COVERAGE_MAX_AGE_DAYS == 365
db = _db_mock_returning(_row(0, None, None, n_with_age=0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
call_args = db.execute.call_args
params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("parameters", {})
assert params["max_age_days"] == 365
sql_text = str(call_args[0][0])
assert "days_on_market <= :max_age_days" in sql_text
# ── DB dedup / cap params passed through ────────────────────────────────────────
def test_coverage_sql_uses_radius_1000_and_area_tolerance(trade_in_app: FastAPI) -> None:
"""SQL params: radius=1000 (строго), area ±15%, rooms exact."""
db = _db_mock_returning({"n_listings": 0, "median_age_days": None, "cohort_city": None})
db = _db_mock_returning(_row(0, None, None, n_with_age=0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
@ -269,3 +363,140 @@ def test_coverage_sql_uses_radius_1000_and_area_tolerance(trade_in_app: FastAPI)
assert params["area_min"] == pytest.approx(50.0 * 0.85)
assert params["area_max"] == pytest.approx(50.0 * 1.15)
assert params["fresh_days"] == 14
# ── MAJOR-1: cohort predicates — sync с estimator._COMMON_WHERE / Tier W ────────
#
# Прямая регрессия из независимого ревью #2894: без этих трёх предикатов проба
# отвечает "ok" в точках, где платный эстиматор (radius Tier W, тот же 1000м)
# реально видит 0 — потому что вся когорта состоит из новостроек / city-centroid
# листингов, которые estimator._COMMON_WHERE / Tier W уже отсекают. Тест ловит
# случайное удаление ЛЮБОГО из трёх предикатов на уровне сгенерированного SQL —
# без живой БД, как и остальные тесты этого файла (см. test_gar_flats_loader.py
# для опционального real-Postgres-варианта аналогичной проверки в этом репо).
def test_cohort_sql_excludes_novostroyki(trade_in_app: FastAPI) -> None:
"""Guard новостроек — sync с estimator._COMMON_WHERE (5460) / Tier W (5932)."""
db = _db_mock_returning(_row(0, None, None, n_with_age=0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
sql_text = str(db.execute.call_args[0][0])
assert "listing_segment IS NULL OR listing_segment = 'vtorichka'" in sql_text
def test_cohort_sql_excludes_city_precision_geocodes(trade_in_app: FastAPI) -> None:
"""geo_precision != 'city' — sync с estimator Tier W (5910/5945-5948, #769 Part E)."""
db = _db_mock_returning(_row(0, None, None, n_with_age=0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
sql_text = str(db.execute.call_args[0][0])
assert "geo_precision IS DISTINCT FROM 'city'" in sql_text
def test_cohort_sql_excludes_zero_price(trade_in_app: FastAPI) -> None:
"""price_rub > 0 — sync с estimator._COMMON_WHERE (5441) / Tier W (5916)."""
db = _db_mock_returning(_row(0, None, None, n_with_age=0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
sql_text = str(db.execute.call_args[0][0])
assert "price_rub > 0" in sql_text
# ── MAJOR-1 (real-DB variant): предикаты реально фильтруют, не только в тексте ──
#
# Опциональный тест против настоящего Postgres (тот же паттерн self-skip, что
# test_gar_flats_loader.py::_live_session) — вставляет novostroyki-строку и
# строку с geo_precision='city' в когорту и проверяет, что они физически НЕ
# посчитаны. Требует TEST_DATABASE_URL/DATABASE_URL, указывающий на реальную
# Postgres+PostGIS БД (не дефолтный localhost:5432/test-заглушку) — иначе skip.
def _live_session(): # type: ignore[no-untyped-def]
try:
from sqlalchemy import create_engine
from sqlalchemy import text as sa_text
from sqlalchemy.orm import sessionmaker
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
if not dsn or "localhost:5432/test" in dsn:
return None
engine = create_engine(dsn, future=True)
conn = engine.connect()
conn.execute(sa_text("SELECT 1"))
conn.close()
return sessionmaker(bind=engine, future=True)()
except Exception:
return None
# Координаты вне Свердловской обл. (реальные данные там ~56-60/58-64) — изолируют
# тестовую когорту от прод-данных без нужды в COMMIT/rollback гимнастики поверх
# чужой транзакции.
_LIVE_LAT, _LIVE_LON = 1.111, 2.222
@pytest.mark.skipif(_live_session() is None, reason="нет доступной Postgres test-БД")
def test_major1_cohort_excludes_novostroyki_and_city_precision_live() -> None:
from sqlalchemy import text as sa_text
from app.api.v1.trade_in import coverage_probe
from app.schemas.trade_in import CoverageProbeInput
db = _live_session()
assert db is not None
try:
rows = [
# (source_url suffix, listing_segment, geo_precision, price_rub) — все
# остальные поля общие: rooms=2, area_m2=50, is_active, scraped_at=NOW().
("ok-vtorichka", None, None, 5_000_000), # counted
("bad-novostroyka", "novostroyki", None, 5_000_000), # excluded
("bad-city-precision", None, "city", 5_000_000), # excluded
("bad-zero-price", None, None, 0), # excluded
]
for suffix, segment, geo_precision, price in rows:
url = f"https://test.invalid/coverage-major1-{suffix}"
db.execute(
sa_text(
"""
INSERT INTO listings
(source, source_url, source_id, dedup_hash, address, lat, lon,
rooms, area_m2, price_rub, is_active, scraped_at,
listing_segment, geo_precision)
VALUES
('test', :url, :url, :url, 'test addr', :lat, :lon,
2, 50.0, :price, true, NOW(), :segment, :geo_precision)
"""
),
{
"url": url,
"lat": _LIVE_LAT,
"lon": _LIVE_LON,
"price": price,
"segment": segment,
"geo_precision": geo_precision,
},
)
result = coverage_probe(
CoverageProbeInput(lat=_LIVE_LAT, lon=_LIVE_LON, rooms=2, area_m2=50.0), db
)
# Только первая (ok-vtorichka) строка должна попадать в когорту —
# каждая следующая вставка не должна сдвигать счётчик.
assert result.n_listings == 1, (
f"predicate regression: n_listings={result.n_listings} after inserting "
f"{suffix!r} (segment={segment!r} geo_precision={geo_precision!r} "
f"price={price}) — expected still 1 (only ok-vtorichka counted)"
)
finally:
db.rollback()
db.close()