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:
parent
d0105470f4
commit
3e9af2fdef
4 changed files with 338 additions and 36 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue