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_AREA_TOLERANCE = 0.15 # ±15% площади
COVERAGE_FRESH_DAYS = 14 # объявления не старше 14 дней (тот же канон, что LISTINGS_FRESH_DAYS) 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 требование), не в БД. # Списки городов и пороги — константа РЯДОМ С РУЧКОЙ (issue #2894 требование), не в БД.
COVERAGE_GREEN_CITIES = ("Екатеринбург", "Верхняя Пышма", "Берёзовский", "Среднеуральск") COVERAGE_GREEN_CITIES = ("Екатеринбург", "Верхняя Пышма", "Берёзовский", "Среднеуральск")
COVERAGE_YELLOW_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]: def _resolve_coverage_city(city_hint: str | None, cohort_city: str | None) -> tuple[str, int, bool]:
"""Резолвит (display_city, threshold, is_supported) для пробы покрытия. """Резолвит (display_city, threshold, is_supported) для пробы покрытия.
Приоритет: явный city_hint фронта (тот же автокомплит, что заполняет MINOR fix (независимый ревью #2894): city_hint — это НЕ проверенный вход,
TradeInEstimateInput.city_hint) > мода city найденной SQL-когорты клиент им управляет напрямую (lat/lon в Серове + city_hint='Екатеринбург'
(best-effort фолбэк, когда фронт его не передал). Город вне зелёного/ раньше давал threshold=8 и status='ok' клиент выбирал себе порог). Источник
жёлтого списка threshold=0, is_supported=False вызывающий обязан истины мода city найденной SQL-когорты (то, что реально лежит в БД рядом с
трактовать это как not_covered независимо от n_listings. переданными 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 match = _COVERAGE_CITY_THRESHOLDS.get(_fold_city(candidate)) if candidate else None
if match is not None: if match is not None:
display, threshold = match display, threshold = match
return display, threshold, True return display, threshold, True
return candidate, 0, False return "", 0, False
@router.post("/coverage", response_model=CoverageProbeResponse) @router.post("/coverage", response_model=CoverageProbeResponse)
@ -2613,8 +2630,23 @@ def coverage_probe(
в module-level import graph): ST_DWithin 1000м, rooms точное совпадение, в module-level import graph): ST_DWithin 1000м, rooms точное совпадение,
area ±15%, scraped_at не старше 14 дней, is_active. 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. В ответе НЕТ ни одной цены см. 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 — реальная (не #oblast (2026-08): house_placement_history.exposure_days — реальная (не
цензурированная) экспозиция history-строк НЕ используется здесь: это цензурированная) экспозиция history-строк НЕ используется здесь: это
house-level архив (join по house_id, не привязан к текущей radius/rooms/ house-level архив (join по house_id, не привязан к текущей radius/rooms/
@ -2648,11 +2680,27 @@ def coverage_probe(
AND ST_DWithin( AND ST_DWithin(
geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius 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 SELECT
count(*) AS n_listings, 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) 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) mode() WITHIN GROUP (ORDER BY city)
FILTER (WHERE city IS NOT NULL) AS cohort_city FILTER (WHERE city IS NOT NULL) AS cohort_city
FROM base FROM base
@ -2669,6 +2717,7 @@ def coverage_probe(
"lon": payload.lon, "lon": payload.lon,
"radius": COVERAGE_RADIUS_M, "radius": COVERAGE_RADIUS_M,
"max_per_addr": MAX_ANALOGS_PER_ADDRESS, "max_per_addr": MAX_ANALOGS_PER_ADDRESS,
"max_age_days": COVERAGE_MAX_AGE_DAYS,
}, },
) )
.mappings() .mappings()
@ -2676,9 +2725,12 @@ def coverage_probe(
) )
n_listings = int(row["n_listings"]) if row else 0 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 = ( median_age = (
round(row["median_age_days"]) 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 else None
) )
cohort_city = row["cohort_city"] if row else None cohort_city = row["cohort_city"] if row else None
@ -2693,18 +2745,20 @@ def coverage_probe(
status = "thin" status = "thin"
logger.info( 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.rooms,
payload.area_m2, payload.area_m2,
city, city,
status, status,
n_listings, n_listings,
n_with_age,
) )
return CoverageProbeResponse( return CoverageProbeResponse(
status=status, status=status,
n_listings=n_listings, n_listings=n_listings,
median_listing_age_days=median_age, median_listing_age_days=median_age,
n_with_age=n_with_age,
radius_m=COVERAGE_RADIUS_M, radius_m=COVERAGE_RADIUS_M,
city=city, city=city,
threshold=threshold, threshold=threshold,

View file

@ -792,6 +792,16 @@ class CoverageProbeResponse(BaseModel):
(days_on_market на текущий момент), а НЕ срок до продажи. Цензурированная (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" для резолвленного threshold n, начиная с которого статус переходит в "ok" для резолвленного
города; 0, если город не входит ни в один список (порог неприменим города; 0, если город не входит ни в один список (порог неприменим
@ -801,6 +811,7 @@ class CoverageProbeResponse(BaseModel):
status: Literal["ok", "thin", "not_covered"] status: Literal["ok", "thin", "not_covered"]
n_listings: int n_listings: int
median_listing_age_days: int | None median_listing_age_days: int | None
n_with_age: int
radius_m: int radius_m: int
city: str city: str
threshold: int 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_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_stuck_transient_house_returns_to_the_queue_by_itself
tests/test_house_imv_retry_stuck.py::test_transient_attempts_counter_only_counts_transient 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 для зелёных/жёлтых/неподдерживаемых городов - пороги ok/thin/not_covered для зелёных/жёлтых/неподдерживаемых городов
- пустая когорта (n=0) not_covered даже в поддерживаемом городе - пустая когорта (n=0) not_covered даже в поддерживаемом городе
- в ответе НЕТ ни одного price-подобного поля (падающий тест на регресс схемы) - в ответе НЕТ ни одного price-подобного поля (падающий тест на регресс схемы)
- city_hint приоритетнее моды city из когорты - MAJOR-1 (независимый ревью #2894): когорта пробы — sync с
- median_listing_age_days median(days_on_market), None при пустой когорте 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 from __future__ import annotations
@ -49,6 +54,25 @@ def trade_in_app() -> FastAPI:
return application 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: def _db_mock_returning(row: dict | None) -> MagicMock:
"""DB session mock — coverage_probe reads db.execute(...).mappings().fetchone().""" """DB session mock — coverage_probe reads db.execute(...).mappings().fetchone()."""
db = MagicMock() 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: 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).""" """Same guard but on a live serialized response (belt-and-suspenders)."""
db = _db_mock_returning( db = _db_mock_returning(_row(10, 21.0, "Екатеринбург"))
{"n_listings": 10, "median_age_days": 21.0, "cohort_city": "Екатеринбург"}
)
_override(trade_in_app, db) _override(trade_in_app, db)
client = TestClient(trade_in_app) 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: def test_green_city_ok_at_threshold(trade_in_app: FastAPI) -> None:
"""Екатеринбург (зелёный, порог 8) — n=8 ровно на границе → ok.""" """Екатеринбург (зелёный, порог 8) — n=8 ровно на границе → ok."""
db = _db_mock_returning( db = _db_mock_returning(_row(8, 15.0, "Екатеринбург"))
{"n_listings": 8, "median_age_days": 15.0, "cohort_city": "Екатеринбург"}
)
_override(trade_in_app, db) _override(trade_in_app, db)
client = TestClient(trade_in_app) 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["city"] == "Екатеринбург"
assert data["radius_m"] == 1000 assert data["radius_m"] == 1000
assert data["median_listing_age_days"] == 15 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: def test_green_city_thin_below_threshold(trade_in_app: FastAPI) -> None:
"""Екатеринбург, n=7 (< порог 8) → thin, не ok и не not_covered.""" """Екатеринбург, n=7 (< порог 8) → thin, не ok и не not_covered."""
db = _db_mock_returning( db = _db_mock_returning(_row(7, 10.0, "Екатеринбург"))
{"n_listings": 7, "median_age_days": 10.0, "cohort_city": "Екатеринбург"}
)
_override(trade_in_app, db) _override(trade_in_app, db)
client = TestClient(trade_in_app) 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: def test_yellow_city_ok_at_threshold(trade_in_app: FastAPI) -> None:
"""Нижний Тагил (жёлтый, порог 12) — n=12 → ok.""" """Нижний Тагил (жёлтый, порог 12) — n=12 → ok. cohort_city совпадает с hint."""
db = _db_mock_returning( db = _db_mock_returning(_row(12, 30.0, "Нижний Тагил"))
{"n_listings": 12, "median_age_days": 30.0, "cohort_city": "Нижний Тагил"}
)
_override(trade_in_app, db) _override(trade_in_app, db)
client = TestClient(trade_in_app) 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: def test_yellow_city_thin_below_threshold(trade_in_app: FastAPI) -> None:
"""Ревда, n=11 (< порог 12) → thin.""" """Ревда, n=11 (< порог 12) → thin. Когорта пуста по городу → используем hint."""
db = _db_mock_returning({"n_listings": 11, "median_age_days": 40.0, "cohort_city": None}) db = _db_mock_returning(_row(11, 40.0, None))
_override(trade_in_app, db) _override(trade_in_app, db)
client = TestClient(trade_in_app) 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: def test_unsupported_city_not_covered_even_with_high_n(trade_in_app: FastAPI) -> None:
"""Город вне списков → not_covered независимо от n_listings (даже n=500).""" """Город вне списков → 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) _override(trade_in_app, db)
client = TestClient(trade_in_app) 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["status"] == "not_covered"
assert data["threshold"] == 0 assert data["threshold"] == 0
assert data["n_listings"] == 500 # честно отдаём счётчик, статус его игнорирует assert data["n_listings"] == 500 # честно отдаём счётчик, статус его игнорирует
assert data["city"] == "" # MINOR: неизвестный город не эхуется сырой строкой
# ── Empty cohort ────────────────────────────────────────────────────────────── # ── 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: def test_empty_cohort_supported_city_not_covered(trade_in_app: FastAPI) -> None:
"""n=0 в поддерживаемом (зелёном) городе → not_covered, не thin — честнее.""" """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) _override(trade_in_app, db)
client = TestClient(trade_in_app) 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["status"] == "not_covered"
assert data["n_listings"] == 0 assert data["n_listings"] == 0
assert data["median_listing_age_days"] is None 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: 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["status"] == "not_covered"
assert data["n_listings"] == 0 assert data["n_listings"] == 0
assert data["median_listing_age_days"] is None 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: def test_cohort_mode_overrides_city_hint_on_mismatch(trade_in_app: FastAPI) -> None:
"""city_hint (фронт) побеждает cohort_city (SQL mode) при определении города.""" """lat/lon в Серове + city_hint='Екатеринбург' — клиент не управляет порогом.
db = _db_mock_returning({"n_listings": 9, "median_age_days": 12.0, "cohort_city": "Серов"})
Когорта реально нашлась в Серове (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) _override(trade_in_app, db)
client = TestClient(trade_in_app) client = TestClient(trade_in_app)
@ -232,12 +274,12 @@ def test_city_hint_overrides_cohort_mode(trade_in_app: FastAPI) -> None:
) )
data = resp.json() data = resp.json()
assert data["city"] == "Екатеринбург" 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: 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) _override(trade_in_app, db)
client = TestClient(trade_in_app) 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 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 ──────────────────────────────────────── # ── DB dedup / cap params passed through ────────────────────────────────────────
def test_coverage_sql_uses_radius_1000_and_area_tolerance(trade_in_app: FastAPI) -> None: def test_coverage_sql_uses_radius_1000_and_area_tolerance(trade_in_app: FastAPI) -> None:
"""SQL params: radius=1000 (строго), area ±15%, rooms exact.""" """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) _override(trade_in_app, db)
client = TestClient(trade_in_app) 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_min"] == pytest.approx(50.0 * 0.85)
assert params["area_max"] == pytest.approx(50.0 * 1.15) assert params["area_max"] == pytest.approx(50.0 * 1.15)
assert params["fresh_days"] == 14 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()