feat(site-finder): additive objective_lots price fallback for competitors (#307 OBJ-3) (#1332)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 1m57s
Deploy / build-worker (push) Successful in 2m51s
Deploy / build-frontend (push) Successful in 3m6s
Deploy / deploy (push) Successful in 1m12s

This commit is contained in:
bot-backend 2026-06-14 16:10:02 +00:00
parent f9dd903f4d
commit b405ae291d
4 changed files with 168 additions and 1 deletions

View file

@ -198,6 +198,11 @@ class Competitor(BaseModel):
sold_pct: float | None
velocity_per_month: float
avg_price_per_m2: float | None
# OBJ-3 (#307): источник avg_price_per_m2 для прозрачности инвест-рекомендаций.
# "domrf" — per-flat цена domrf_kn_flats (приоритет); "objective" — медиана
# objective_lots.price_per_m2_rub через objective_complex_mapping (additive
# fallback, заполняет пробелы где domrf пуст); None — цены нет ни там, ни там.
price_source: str | None = None
is_active: bool
# #949 PR B (ТЗ §9.1, §16): детерминированный relevance_weight ∈ [0,1] —
# взвешенная комбинация geo_proximity / class_similarity / price_similarity /

View file

@ -473,6 +473,37 @@ _AVG_PRICE_SQL = text("""
GROUP BY f.obj_id
""")
# OBJ-3 (#307): additive fallback цены м² из objective_lots, когда domrf-цены нет.
# Замер прода 2026-06: на ПОСЛЕДНЕМ snapshot domrf_kn_flats.price_per_m2 = 0 строк
# (скрапер перестал отдавать per-flat цены), поэтому _AVG_PRICE_SQL выше даёт
# avg_price_per_m2=None почти всем конкурентам. objective_lots.price_per_m2_rub
# покрывает ~80% (919795/1123771 строк) и мостится к domrf-obj_id через
# objective_complex_mapping (то же звено, что _INLINE_VELOCITY_SQL в best_layouts).
#
# Мост: domrf_kn_objects.obj_id == objective_complex_mapping.domrf_obj_id
# → objective_complex_mapping.objective_complex_name == objective_lots.project_name.
# Маппинг 1:1 (прод: 263 строки, 263 distinct obj_id, 0 obj_id с >1 именем), поэтому
# на obj_id приходится РОВНО один project_name; корпуса проекта агрегируются в один
# проект естественно. Берём МЕДИАНУ price_per_m2_rub по всем лотам проекта
# (percentile_cont 0.5) — устойчива к хвостовым выбросам пентхаусов/студий, в отличие
# от AVG. Прод-санити: медиана медиан 136k руб/м² (диапазон 80k250k для ЕКБ ✓).
#
# Additive-контракт: возвращаем ТОЛЬКО obj_id, у которых objective-цена есть; в Python
# заполняем пробелы (domrf-цена приоритетна, objective — fallback). Существующие
# непустые domrf-выводы НЕ меняются. price_source делает источник прозрачным.
_OBJECTIVE_PRICE_FALLBACK_SQL = text("""
SELECT
cm.domrf_obj_id AS obj_id,
percentile_cont(0.5) WITHIN GROUP (ORDER BY ol.price_per_m2_rub)
AS median_price_per_m2
FROM objective_complex_mapping cm
JOIN objective_lots ol
ON ol.project_name = cm.objective_complex_name
WHERE cm.domrf_obj_id = ANY(:obj_ids)
AND ol.price_per_m2_rub IS NOT NULL
GROUP BY cm.domrf_obj_id
""")
def _quarter_from_cad(cad_num: str) -> str:
"""Извлечь кадастровый квартал из номера участка/здания.
@ -579,6 +610,7 @@ def get_competitors(
# Читаем через _row_get: существующие тесты мокируют только avg_price_per_m2,
# отсутствующий ключ flats_sold → None (не KeyError) → нейтральный stage.
avg_price_map: dict[int, float] = {}
price_source_map: dict[int, str] = {}
sold_count_map: dict[int, int] = {}
try:
price_rows = db.execute(_AVG_PRICE_SQL, {"obj_ids": obj_ids}).mappings().all()
@ -587,12 +619,36 @@ def get_competitors(
price = _row_get(r, "avg_price_per_m2")
if price is not None:
avg_price_map[oid] = float(price)
price_source_map[oid] = "domrf"
sold = _row_get(r, "flats_sold")
if sold is not None:
sold_count_map[oid] = int(sold)
except Exception:
logger.warning("competitors: avg_price query failed, continuing without prices")
# ── 4a. OBJ-3 (#307) additive fallback: где domrf-цены НЕТ → objective_lots ──
# Заполняем ТОЛЬКО пробелы (obj_id без domrf-цены). Существующие domrf-выводы
# не трогаем (precedence: domrf > objective). Прод 2026-06: domrf на последнем
# snapshot = 0 цен → почти все obj_id попадают в этот fallback (~80% покрытие).
missing_price_ids = [oid for oid in obj_ids if oid not in avg_price_map]
if missing_price_ids:
try:
obj_price_rows = (
db.execute(_OBJECTIVE_PRICE_FALLBACK_SQL, {"obj_ids": missing_price_ids})
.mappings()
.all()
)
for r in obj_price_rows:
oid = int(r["obj_id"])
price = _row_get(r, "median_price_per_m2")
if price is not None and oid not in avg_price_map:
avg_price_map[oid] = float(price)
price_source_map[oid] = "objective"
except Exception:
logger.warning(
"competitors: objective price fallback failed, continuing with domrf only"
)
# ── 4b. Локальные эталоны для relevance (медиана цены, доминирующий класс) ─
radius_m = request.radius_km * 1000.0
local_median_ppm2 = _median(list(avg_price_map.values()))
@ -656,6 +712,7 @@ def get_competitors(
sold_pct=sold_pct,
velocity_per_month=velocity,
avg_price_per_m2=avg_price_map.get(obj_id),
price_source=price_source_map.get(obj_id),
is_active=is_active,
relevance_weight=round(relevance_weight, 3),
relevance_breakdown=relevance_breakdown,

View file

@ -53,6 +53,13 @@ def _price_row(obj_id: int, price: float) -> MagicMock:
return r
def _obj_price_row(obj_id: int, price: float) -> MagicMock:
"""Строка objective-fallback цены (_OBJECTIVE_PRICE_FALLBACK_SQL, OBJ-3 #307)."""
r = MagicMock()
r.__getitem__ = lambda self, k: {"obj_id": obj_id, "median_price_per_m2": price}[k]
return r
# ── Построение mock DB ────────────────────────────────────────────────────────
@ -60,13 +67,17 @@ def _make_db(
coord: MagicMock | None = None,
obj_rows: list[MagicMock] | None = None,
price_rows: list[MagicMock] | None = None,
obj_price_rows: list[MagicMock] | None = None,
) -> MagicMock:
"""Сконструировать mock Session.
Порядок вызовов execute:
1. centroid query coord
2. competitors query obj_rows
3. avg_price query price_rows
3. domrf avg_price query price_rows
4. objective price fallback (OBJ-3 #307) → obj_price_rows
(вызывается ТОЛЬКО если у части obj_id нет domrf-цены почти всегда,
т.к. domrf на последнем snapshot пуст; по умолчанию [] = fallback пуст)
"""
db = MagicMock()
@ -75,6 +86,7 @@ def _make_db(
(coord, True),
(obj_rows or [], False),
(price_rows or [], False),
(obj_price_rows or [], False),
]:
result = MagicMock()
if is_first:
@ -390,6 +402,97 @@ def test_competitors_avg_price_populated() -> None:
assert comp["avg_price_per_m2"] == pytest.approx(150_000.0), (
"avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter"
)
# OBJ-3 #307: domrf-hit → price_source='domrf'.
assert comp["price_source"] == "domrf"
finally:
app.dependency_overrides.clear()
# ── OBJ-3 (#307) additive fallback из objective_lots ────────────────────────────
def test_competitors_objective_price_fallback_fills_gap() -> None:
"""domrf-цены НЕТ → objective_lots заполняет avg_price_per_m2 (OBJ-3 #307).
Прод 2026-06: domrf_kn_flats.price_per_m2 на последнем snapshot = 0 строк,
поэтому domrf даёт None почти всем. objective_lots (через
objective_complex_mapping) покрывает ~80% этот fallback восстанавливает цену.
"""
rows = [_obj_row(obj_id=1)]
# domrf price пуст → fallback на objective median.
obj_price_rows = [_obj_price_row(obj_id=1, price=136_000.0)]
db = _make_db(
coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=obj_price_rows
)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
comp = resp.json()["competitors"][0]
assert comp["avg_price_per_m2"] == pytest.approx(136_000.0)
assert comp["price_source"] == "objective"
finally:
app.dependency_overrides.clear()
def test_competitors_domrf_price_wins_over_objective() -> None:
"""domrf-цена приоритетна: objective НЕ перетирает существующий domrf-вывод.
Additive-контракт OBJ-3: где domrf есть не трогаем; objective только пробелы.
"""
rows = [_obj_row(obj_id=1)]
price_rows = [_price_row(obj_id=1, price=150_000.0)]
# objective тоже вернул бы цену, но domrf уже есть → fallback её игнорирует.
obj_price_rows = [_obj_price_row(obj_id=1, price=99_000.0)]
db = _make_db(
coord=_coord_row(),
obj_rows=rows,
price_rows=price_rows,
obj_price_rows=obj_price_rows,
)
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
comp = resp.json()["competitors"][0]
assert comp["avg_price_per_m2"] == pytest.approx(150_000.0)
assert comp["price_source"] == "domrf"
finally:
app.dependency_overrides.clear()
def test_competitors_no_price_anywhere_is_none() -> None:
"""Ни domrf, ни objective → avg_price_per_m2 None, price_source None (graceful)."""
rows = [_obj_row(obj_id=1)]
db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=[])
from app.core.db import get_db
app.dependency_overrides[get_db] = _override_db(db)
try:
client = TestClient(app)
resp = client.post(
"/api/v1/parcels/66:41:0303161:5/competitors",
json={"radius_km": 1.0, "time_window": "last_quarter"},
)
assert resp.status_code == 200, resp.text
comp = resp.json()["competitors"][0]
assert comp["avg_price_per_m2"] is None
assert comp["price_source"] is None
finally:
app.dependency_overrides.clear()

View file

@ -3070,6 +3070,8 @@ export interface components {
velocity_per_month: number;
/** Avg Price Per M2 */
avg_price_per_m2: number | null;
/** Price Source */
price_source?: string | null;
/** Is Active */
is_active: boolean;
/** Relevance Weight */