fix(best-layouts): address review-bot minor items (#113 PR C)

3 carryover 🟡 items из re-review verdict на a88917f:

1. Coverage % denominator now post-filter (was mixing pre-filter count
   с post-filter intersection → artificially low confidence when
   user passed exclude/filter list)
2. MAX(snapshot_date) pre-computed via db.scalar() + bind :latest_snap
   instead of subquery in WHERE (avoid re-execution per spatial scan)
3. LAYOUT_CONFIDENCE_HIGH_PCT / LAYOUT_CONFIDENCE_MEDIUM_PCT as
   module-level constants instead of hardcoded literals

Integration test gap (#4 в re-review) — отдельный follow-up issue,
не в scope этого PR.

Tests: 10/10 best-layouts pass, 43/43 regression OK.
This commit is contained in:
lekss361 2026-05-16 12:01:09 +03:00
parent a88917f0cf
commit 8488012f05
2 changed files with 46 additions and 21 deletions

View file

@ -39,6 +39,11 @@ from app.services.site_finder.layout_signature import area_bin, layout_signature
logger = logging.getLogger(__name__)
# Confidence thresholds (per coverage % of objects with MV velocity data)
# Tune via PR if business feedback требует.
LAYOUT_CONFIDENCE_HIGH_PCT = 50.0
LAYOUT_CONFIDENCE_MEDIUM_PCT = 20.0
# Делители velocity: 24 мес → масштаб на указанный window
_VELOCITY_DIVISORS: dict[str, float] = {
"last_month": 24.0,
@ -139,7 +144,7 @@ _SUPPLY_BATCH_SQL = text("""
)::geography,
CAST(:radius_m AS float)
)
AND f.snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_kn_flats)
AND f.snapshot_date = CAST(:latest_snap AS date)
GROUP BY rb, ab
""")
@ -279,18 +284,29 @@ def get_best_layouts(
)
# ── Step 5: supply side (батч-запрос) ────────────────────────────────────
try:
supply_rows = (
db.execute(
_SUPPLY_BATCH_SQL,
{"center_lon": center_lon, "center_lat": center_lat, "radius_m": radius_m},
)
.mappings()
.all()
)
except Exception:
logger.warning("best_layouts: supply query failed, supply=0 fallback")
# Pre-compute последний snapshot_date один раз — избегаем subquery на каждый scan.
latest_snap: dt.date | None = db.scalar(text("SELECT MAX(snapshot_date) FROM domrf_kn_flats"))
if latest_snap is None:
logger.warning("best_layouts: domrf_kn_flats пустой (нет snapshot_date), supply=0 fallback")
supply_rows = []
else:
try:
supply_rows = (
db.execute(
_SUPPLY_BATCH_SQL,
{
"center_lon": center_lon,
"center_lat": center_lat,
"radius_m": radius_m,
"latest_snap": latest_snap,
},
)
.mappings()
.all()
)
except Exception:
logger.warning("best_layouts: supply query failed, supply=0 fallback")
supply_rows = []
supply_map: dict[tuple[str, str], int] = {
(str(r["rb"]), str(r["ab"])): int(r["units"]) for r in supply_rows
@ -410,22 +426,24 @@ def get_best_layouts(
)
# ── Step 9: data_quality ─────────────────────────────────────────────────
# Denominator = post-filter set (effective consideration set после exclude/filter).
objects_total_after_filter = len(all_obj_ids)
objects_with_data = len(obj_ids_with_data & set(all_obj_ids))
coverage_pct = (
round(objects_with_data / objects_total_in_radius * 100.0, 1)
if objects_total_in_radius > 0
round(objects_with_data / objects_total_after_filter * 100.0, 1)
if objects_total_after_filter > 0
else 0.0
)
if coverage_pct >= 50.0:
if coverage_pct >= LAYOUT_CONFIDENCE_HIGH_PCT:
confidence: str = "high"
elif coverage_pct >= 20.0:
elif coverage_pct >= LAYOUT_CONFIDENCE_MEDIUM_PCT:
confidence = "medium"
else:
confidence = "low"
data_quality = LayoutDataQuality(
objects_with_velocity_data=objects_with_data,
objects_total_in_radius=objects_total_in_radius,
objects_total_in_radius=objects_total_after_filter,
velocity_coverage_pct=coverage_pct,
confidence=confidence, # type: ignore[arg-type]
)

View file

@ -3,11 +3,13 @@
Mock-based не требуют живой БД.
Паттерн mock DB: аналогично test_parcel_competitors.py dependency_overrides[get_db].
Порядок вызовов db.execute в get_best_layouts:
Порядок вызовов в get_best_layouts:
db.scalar() MAX(snapshot_date) (только когда vel_rows non-empty)
db.execute() calls:
1. _PARCEL_CENTROID_SQL .mappings().first()
2. _COMPETITORS_IN_RADIUS_SQL .mappings().all()
3. _VELOCITY_BY_ROOM_SQL .mappings().all()
4. _SUPPLY_BATCH_SQL .mappings().all()
4. _SUPPLY_BATCH_SQL .mappings().all() (пропускается если latest_snap is None)
"""
from __future__ import annotations
@ -83,17 +85,22 @@ def _make_db(
id_rows: list[MagicMock] | None = None,
vel_rows: list[MagicMock] | None = None,
supply_rows: list[MagicMock] | None = None,
latest_snap: dt.date | None = None,
) -> MagicMock:
"""Сконструировать mock Session.
Порядок execute:
db.scalar() возвращает latest_snap (MAX snapshot_date) вызывается перед supply.
Порядок db.execute():
1. centroid .mappings().first()
2. competitors-in-radius .mappings().all()
3. velocity .mappings().all()
4. supply .mappings().all()
4. supply .mappings().all() (только если latest_snap is not None)
"""
db = MagicMock()
# db.scalar — pre-computed MAX(snapshot_date) для supply query
db.scalar.return_value = latest_snap if latest_snap is not None else _TODAY
results: list[MagicMock] = []
# 1: centroid