fix(analytics): recommend_mix success-boost regression from view 172 (#1955)
Some checks failed
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Successful in 1m0s
CI / openapi-codegen-check (pull_request) Failing after 1m51s
CI / backend-tests (pull_request) Successful in 13m47s

Миграция 172 убрала english 'Comfort' из v_bucket_success_score (NULL-class
→ 'не указан'), но _bucket_success_ranking в analytics_queries.py матчил
COALESCE(:cls, 'Comfort') — после 172 ни одна строка не равна 'Comfort' →
recommend_mix success-boost (#25, /analytics/recommend квартирография) тихо
возвращал [] (silent degradation). LIVE на проде с момента ручного применения
172 (graceful empty, без краша).

Fix:
- call site (recommend_mix): передаём target_class_db (уже переведённый через
  _class_to_db_vocab english→русский), а не сырой english target_class.
  Чинит и латентный pre-existing баг: Business/Elite никогда не матчили
  русский источник.
- SQL default 'Comfort' → 'Комфорт' (массовый класс ЕКБ, 723 объекта).
  NULL-class ('не указан') в default-путь намеренно не попадают
  (документировано в docstring).

Prod-verified: OLD 'Comfort' default → 0 строк; NEW 'Комфорт' default → 5.

Tests:
- _bucket_success_ranking: реальный запрос (не замокан) с русским default —
  SQL содержит 'Комфорт', не 'Comfort'; ranking непустой.
- recommend_mix: english 'Business' переводится в 'Бизнес' перед ranking
  (раньше тест патчил _bucket_success_ranking→[] и баг не ловил;
  helper теперь умеет patch_success_ranking=False).
- #1960: позитивный тест выбора quarter_rosreestr basis (deals≥5, все
  higher fallback'и None) → median_price_basis='quarter_rosreestr'.
This commit is contained in:
Light1YT 2026-06-27 15:49:40 +05:00
parent 453a1f08da
commit a184d38aa6
3 changed files with 210 additions and 69 deletions

View file

@ -1814,8 +1814,7 @@ def _active_competitors_count(
# #38: реальный obj_class в приоритете, иначе obj_class_fallback.
if target_class:
n = _q(
"AND district_name = :dn"
" AND COALESCE(obj_class, obj_class_fallback) = :cls",
"AND district_name = :dn" " AND COALESCE(obj_class, obj_class_fallback) = :cls",
{"rc": region_code, "dn": district_name, "cls": target_class},
)
if n >= 2:
@ -2221,9 +2220,7 @@ def _competitors_two_dim(
return 0, n, float(n), scope
# #38: реальный obj_class в приоритете, иначе obj_class_fallback.
class_filter = (
"AND COALESCE(obj_class, obj_class_fallback) = :cls" if target_class else ""
)
class_filter = "AND COALESCE(obj_class, obj_class_fallback) = :cls" if target_class else ""
params: dict[str, Any] = {
"rc": region_code,
"dn": district_name,
@ -2291,6 +2288,17 @@ def _bucket_success_ranking(
Возвращает список dict {bucket, success_score, n_deals, velocity_z,
price_z, area_z}, sorted DESC by success_score. Пустой список если
данных нет или district_name не передан.
target_class ДОЛЖЕН быть в словаре БД (русский) caller переводит через
_class_to_db_vocab. Default-класс (когда target_class is None) = 'Комфорт':
после миграции 172 view хранит только канонические русские классы
('Комфорт'/'Типовой'/'Бизнес'/'Элит'/'Премиум'/'не указан'), английский
'Comfort' больше не существует и матчил бы 0 строк (#1955). 'Комфорт' —
самый массовый класс ЕКБ (723 объекта) разумный baseline для
«класс не указан в запросе». NULL-class строки ('не указан', бывшие
obj_class IS NULL) в default-путь НЕ попадают намеренно: для рекомендации
квартирографии лучше показать реальный массовый класс, чем агрегат «не
указан»; явный запрос с target_class='не указан' их при этом достанет.
"""
if not district_name:
return []
@ -2301,7 +2309,7 @@ def _bucket_success_ranking(
SELECT bucket, success_score, n_deals, velocity_z, price_z, area_z
FROM v_bucket_success_score
WHERE district_name = :dn
AND obj_class = COALESCE(:cls, 'Comfort')
AND obj_class = COALESCE(:cls, 'Комфорт')
ORDER BY success_score DESC
"""
),
@ -2742,8 +2750,11 @@ def recommend_mix(
# #22 Noise penalty: плотность шумных объектов района → штраф до -10% цены
noise_penalty, noise_breakdown = _noise_penalty_factor(db, district_row["district_name"])
# #25 Success-driven ranking из v_bucket_success_score
success_ranking = _bucket_success_ranking(db, district_row["district_name"], target_class)
# #25 Success-driven ranking из v_bucket_success_score.
# #1955: передаём target_class_db (русский словарь БД), НЕ сырой английский
# target_class — иначе obj_class = :cls молча даёт ноль строк ("Comfort" !=
# "Комфорт"), success-boost тихо деградирует в [].
success_ranking = _bucket_success_ranking(db, district_row["district_name"], target_class_db)
# 5b-3) Per-bucket project velocity at price_factor=1.0:
# bucket_market_v = per-bucket velocity из objective или rosreestr/N_active_region.

View file

@ -385,6 +385,37 @@ def test_district_median_quarter_rosreestr_gated_by_min_deals() -> None:
_stop_patches()
def test_district_median_quarter_rosreestr_selected_as_last_resort() -> None:
"""#1960: если ВСЕ newbuild-fallback'и None (нет Objective-по-имени, нет
geo_radius, нет ekb_reference), но квартальная росреестровская медиана имеет
deals_count 5 она выбирается как basis (4-й fallback) с явным источником."""
from app.core.db import get_db
db = _make_db_for_analyze(
district_row_override={
"district_name": "Глухой",
"ekb_reference_median": None, # нет справочной DDU-медианы
"quarter_median_12m": 88000,
"quarter_deals_count": 7, # ≥ 5 → проходит guard
"dist_to_center": 3000.0,
},
district_price_row=None, # нет Objective по имени
geo_radius_row=None, # нет geo_radius
)
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
district = resp.json()["district"]
assert district["median_price_basis"] == "quarter_rosreestr"
assert district["median_price_per_m2"] == pytest.approx(88000.0)
finally:
app.dependency_overrides.clear()
_stop_patches()
# ── #1955: obj_class passthrough в success_recommendation ────────────────────────

View file

@ -14,6 +14,7 @@ analytics_queries + прямые unit-тесты helper-функций.
from __future__ import annotations
import contextlib
from typing import Any
from unittest.mock import MagicMock, patch
@ -36,9 +37,7 @@ _CITY_BUCKET_DEALS = {
_TOTAL_DEALS = sum(_CITY_BUCKET_DEALS.values()) # 3800
def _make_bucket_row(
bucket_id: str, deals: int, area_avg: float = 40.0
) -> MagicMock:
def _make_bucket_row(bucket_id: str, deals: int, area_avg: float = 40.0) -> MagicMock:
r = MagicMock()
data = {
"bucket": bucket_id,
@ -111,7 +110,9 @@ class TestVelocityBaselinePerBucket:
]:
r = MagicMock()
r.__getitem__ = lambda self, k, _bid=bid, _med=median_pm, _obs=obs: {
"bucket_id": _bid, "median_pm": _med, "observations": _obs,
"bucket_id": _bid,
"median_pm": _med,
"observations": _obs,
}[k]
rows.append(r)
db.execute.return_value.mappings.return_value.all.return_value = rows
@ -131,12 +132,14 @@ class TestVelocityBaselinePerBucket:
db = MagicMock()
rows = []
for bid, median_pm, obs in [
("1-Студия", 3.0, 2), # < 3 наблюдений → пропускаем
("2-1-к", 5.0, 10), # OK
("1-Студия", 3.0, 2), # < 3 наблюдений → пропускаем
("2-1-к", 5.0, 10), # OK
]:
r = MagicMock()
r.__getitem__ = lambda self, k, _bid=bid, _med=median_pm, _obs=obs: {
"bucket_id": _bid, "median_pm": _med, "observations": _obs,
"bucket_id": _bid,
"median_pm": _med,
"observations": _obs,
}[k]
rows.append(r)
db.execute.return_value.mappings.return_value.all.return_value = rows
@ -157,7 +160,9 @@ class TestVelocityBaselinePerBucket:
for bid, obs in [("1-Студия", 1), ("2-1-к", 2)]:
r = MagicMock()
r.__getitem__ = lambda self, k, _bid=bid, _obs=obs: {
"bucket_id": _bid, "median_pm": 3.0, "observations": _obs,
"bucket_id": _bid,
"median_pm": 3.0,
"observations": _obs,
}[k]
rows.append(r)
db.execute.return_value.mappings.return_value.all.return_value = rows
@ -208,7 +213,7 @@ class TestRosreestrFallbackPerBucketVelocity:
def test_velocity_scales_with_competitor_count(self) -> None:
"""При большем n_comp velocity одного проекта меньше (делим рынок на больше ЖК)."""
v_few = 710 / 24 / 5 # 5 конкурентов
v_few = 710 / 24 / 5 # 5 конкурентов
v_many = 710 / 24 / 15 # 15 конкурентов
assert v_few > v_many
assert v_few == pytest.approx(710 / 24 / 5, rel=0.001)
@ -241,30 +246,35 @@ def _make_full_mock_db(has_class_data: bool = False) -> MagicMock:
"mean_price_per_m2": 112_000.0,
}[k]
# Sequence для прямых db.execute calls
calls: list[MagicMock] = []
# Сигнатурный диспетчер вместо позиционной последовательности — устойчив к
# доп. запросам class-пути (#1955: class_multiplier yandex_realty_zk
# выполняется ТОЛЬКО когда target_class задан → лишний db.execute, который
# ломал бы жёсткую нумерацию).
def _dispatch(stmt: Any, params: Any = None) -> MagicMock:
sql = " ".join(str(stmt).split())
r = MagicMock()
# district_row
if "FROM ekb_districts" in sql and "district_name ILIKE" in sql:
r.mappings.return_value.first.return_value = dr
# has_class_data
elif "FROM domrf_kn_objects" in sql and "obj_class_fallback" in sql:
r.scalar.return_value = 1 if has_class_data else None
# class_multiplier (yandex_realty_zk) — только class-путь
elif "comfort_avg" in sql or "yandex_realty_zk" in sql:
cr = MagicMock()
cr.__getitem__ = lambda self, k: {"class_avg": None, "comfort_avg": None}[k]
r.mappings.return_value.first.return_value = cr
# comparables → пусто
elif "latest_agg" in sql or "domrf_kn_sales_agg" in sql:
r.mappings.return_value.all.return_value = []
# city_median (любой оставшийся scalar)
else:
r.scalar.return_value = 110_000.0
r.mappings.return_value.all.return_value = []
r.mappings.return_value.first.return_value = None
return r
# 1) district_row
r1 = MagicMock()
r1.mappings.return_value.first.return_value = dr
calls.append(r1)
# 2) city_median scalar
r2 = MagicMock()
r2.scalar.return_value = 110_000.0
calls.append(r2)
# 3) has_class_data scalar
r3 = MagicMock()
r3.scalar.return_value = 1 if has_class_data else None
calls.append(r3)
# 4) comparables query → пустой
r4 = MagicMock()
r4.mappings.return_value.all.return_value = []
calls.append(r4)
db.execute.side_effect = calls
db.execute.side_effect = _dispatch
return db
@ -276,6 +286,8 @@ def _run_recommend_mix_full(
area_total_m2: float = 12_000.0,
horizon_months: int | None = None,
cad_num: str | None = None,
target_class: str | None = None,
patch_success_ranking: bool = True,
) -> dict[str, Any]:
"""Запускает recommend_mix с правильным набором моков.
@ -284,10 +296,16 @@ def _run_recommend_mix_full(
horizon_months/cad_num #982 forecast-overlay opt-in (по умолчанию None →
живой микс БАЙТ-в-БАЙТ как раньше; существующие вызовы не затронуты).
target_class #1955: проверка перевода english→русский перед success-ranking.
has_class_data=True когда target_class задан, чтобы class-путь вернул строки.
patch_success_ranking если False, НЕ патчим _bucket_success_ranking (даём
реальной функции/внешнему spy отработать; #1955 translation-тест).
"""
from app.services.analytics_queries import recommend_mix
db = _make_full_mock_db()
db = _make_full_mock_db(has_class_data=target_class is not None)
patches = [
patch(f"{_MOD}._bucket_distribution", return_value=_city_bucket_rows()),
@ -321,32 +339,19 @@ def _run_recommend_mix_full(
),
patch(f"{_MOD}._current_mortgage_rate", return_value=(None, None)),
patch(f"{_MOD}._noise_penalty_factor", return_value=(1.0, [])),
patch(f"{_MOD}._bucket_success_ranking", return_value=[]),
patch(f"{_MOD}._recommend_data_last_updated", return_value=None),
]
if patch_success_ranking:
patches.append(patch(f"{_MOD}._bucket_success_ranking", return_value=[]))
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patches[6],
patches[7],
patches[8],
patches[9],
patches[10],
patches[11],
patches[12],
patches[13],
patches[14],
):
with contextlib.ExitStack() as stack:
for p in patches:
stack.enter_context(p)
return recommend_mix(
db,
district_name="Ленинский",
area_total_m2=area_total_m2,
target_class=None,
target_class=target_class,
months_window=24,
region_code=66,
horizon_months=horizon_months,
@ -417,9 +422,9 @@ class TestRealisticSrokFallback:
)
srok = result["summary"]["months_to_sellout_total"]
assert srok is not None
assert lo <= srok <= hi, (
f"n_comp={n_comp}, area={area}: срок {srok:.1f} вне [{lo}, {hi}]"
)
assert (
lo <= srok <= hi
), f"n_comp={n_comp}, area={area}: срок {srok:.1f} вне [{lo}, {hi}]"
def test_scope_has_n_competitors(self) -> None:
"""scope.n_competitors присутствует и равен district+class competitors."""
@ -487,9 +492,10 @@ class TestPerBucketVelocityVariesByBucket:
)
for b in result["buckets"]:
assert "velocity_source" in b, f"Бакет '{b['bucket']}' не имеет velocity_source"
assert b["velocity_source"] in ("rosreestr_fallback", "objective_per_bucket"), (
f"Неожиданное velocity_source='{b['velocity_source']}'"
)
assert b["velocity_source"] in (
"rosreestr_fallback",
"objective_per_bucket",
), f"Неожиданное velocity_source='{b['velocity_source']}'"
class TestObjectivePerBucketPath:
@ -516,9 +522,9 @@ class TestObjectivePerBucketPath:
# Studio: macro_mult = sat_factor × trend_factor = 1.0 × 1.0 = 1.0
studio = bkt_map.get("Студии 15-30")
assert studio is not None
assert studio["velocity_per_month"] == pytest.approx(3.5, rel=0.01), (
f"Studio velocity={studio['velocity_per_month']:.3f}, ожидалось 3.5"
)
assert studio["velocity_per_month"] == pytest.approx(
3.5, rel=0.01
), f"Studio velocity={studio['velocity_per_month']:.3f}, ожидалось 3.5"
assert studio.get("velocity_source") == "objective_per_bucket"
def test_objective_velocities_vary(self) -> None:
@ -621,3 +627,96 @@ class TestForecastOverlayOptIn:
assert result["scope"]["forecast"]["advisory"] is True
assert "boom" in result["scope"]["forecast"]["error"]
assert set(result.keys()) == {"scope", "buckets", "summary", "comparables"}
# ── #1955: _bucket_success_ranking использует русский default (НЕ english 'Comfort')
def _make_ranking_row(bucket: str, score: float, n: int) -> MagicMock:
r = MagicMock()
data = {
"bucket": bucket,
"success_score": score,
"n_deals": n,
"velocity_z": 0.1,
"price_z": 0.2,
"area_z": -0.1,
}
r.__getitem__ = lambda self, k: data[k]
return r
class TestBucketSuccessRankingDefault:
"""#1955: после миграции 172 view хранит только русские классы — default
в _bucket_success_ranking ДОЛЖЕН быть 'Комфорт', не english 'Comfort'
(иначе obj_class = 'Comfort' матчит 0 строк success-boost тихо пустеет)."""
def test_default_class_is_russian_komfort_in_sql(self) -> None:
"""SQL использует COALESCE(:cls, 'Комфорт'), НЕ 'Comfort'."""
from app.services.analytics_queries import _bucket_success_ranking
captured: dict[str, Any] = {}
def _exec(stmt: Any, params: Any) -> MagicMock:
captured["sql"] = " ".join(str(stmt).split())
captured["params"] = params
# Реальный запрос отрабатывает и возвращает строки (как было бы с
# русским default-классом в обновлённом view).
return _make_mapping_result([_make_ranking_row("1-к 30-45", 1.2, 1029)])
db = MagicMock()
db.execute.side_effect = _exec
# target_class=None → должен сработать default-путь.
result = _bucket_success_ranking(db, "Железнодорожный", None)
assert "'Комфорт'" in captured["sql"], captured["sql"]
assert "'Comfort'" not in captured["sql"], "english 'Comfort' default — баг #1955"
# Реальный запрос (не замокан в []) вернул непустой ranking.
assert result, "success_ranking пуст — default-класс не матчит view (#1955)"
assert result[0]["bucket"] == "1-к 30-45"
assert result[0]["n_deals"] == 1029
def test_explicit_russian_class_passed_through(self) -> None:
"""Явный русский класс уходит в bind как есть (caller уже перевёл)."""
from app.services.analytics_queries import _bucket_success_ranking
captured: dict[str, Any] = {}
def _exec(stmt: Any, params: Any) -> MagicMock:
captured["params"] = params
return _make_mapping_result([_make_ranking_row("Студии 15-30", 0.9, 4857)])
db = MagicMock()
db.execute.side_effect = _exec
result = _bucket_success_ranking(db, "Академический", "Комфорт")
assert captured["params"]["cls"] == "Комфорт"
assert result and result[0]["n_deals"] == 4857
class TestRecommendMixTranslatesClassForSuccessRanking:
"""#1955: recommend_mix ДОЛЖЕН передавать переведённый русский класс
(target_class_db) в _bucket_success_ranking, а не сырой english target_class
из UI ('Comfort' != 'Комфорт' 0 строк)."""
def test_english_ui_class_translated_before_ranking_query(self) -> None:
from app.services.analytics_queries import _class_to_db_vocab
captured: dict[str, Any] = {}
def _spy_ranking(db: Any, dn: str | None, cls: str | None) -> list[dict]:
captured["cls"] = cls
return []
with patch(f"{_MOD}._bucket_success_ranking", side_effect=_spy_ranking):
# UI шлёт english 'Business' → recommend_mix должен передать русский 'Бизнес'.
# patch_success_ranking=False → helper НЕ перепатчивает наш spy на [].
_run_recommend_mix_full(
objective_per_bucket=None,
target_class="Business",
patch_success_ranking=False,
)
assert captured["cls"] == _class_to_db_vocab("Business") == "Бизнес"
assert captured["cls"] != "Business", "сырой english класс утёк в ranking-запрос (#1955)"