feat(site-finder): inline POI weights pass-through в /analyze (#201 Phase 1)

Critical UX fix для #114 — user-drag слайдеры в WeightProfilePanel
теперь применяются immediately к scoring, без обязательного profile save.

Backend (parcels.py + schemas/parcel.py + tests):
- POST /analyze принимает optional AnalyzeRequest { weights: dict[str,float] | None }
- Priority: inline → profile_id → user_default → system defaults
- Validate against ALLOWED_CATEGORIES + [MIN_WEIGHT, MAX_WEIGHT] → 422 на violation
- Partial override semantics
- 5 mock tests

Frontend (useSiteAnalysis.ts + page.tsx):
- weights param в analyze mutation
- handleAnalyze всегда передаёт currentWeights когда activeProfileId=null
- handleWeightsChange re-trigger analyze immediately если parcel loaded

Phase 2 (debounce) + Phase 3 (Edit/Delete UI) — follow-up.
This commit is contained in:
lekss361 2026-05-16 13:39:14 +03:00
parent 434341e98f
commit 038e39e2ec
5 changed files with 419 additions and 15 deletions

View file

@ -6,7 +6,7 @@ import time
from typing import Annotated, Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response
from shapely import wkt as _shp_wkt
from shapely.geometry import Polygon
from sqlalchemy import text
@ -15,6 +15,7 @@ from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
from app.schemas.parcel import (
AnalyzeRequest,
BestLayoutsRequest,
BestLayoutsResponse,
CompetitorsRequest,
@ -41,6 +42,18 @@ from app.services.site_finder.quarter_dump_lookup import (
make_empty_result,
)
from app.services.site_finder.velocity import compute_velocity
from app.services.site_finder.weight_profiles import (
ALLOWED_CATEGORIES as _ALLOWED_CATEGORIES,
)
from app.services.site_finder.weight_profiles import (
MAX_WEIGHT as _MAX_WEIGHT,
)
from app.services.site_finder.weight_profiles import (
MIN_WEIGHT as _MIN_WEIGHT,
)
from app.services.site_finder.weight_profiles import (
resolve_weights as _resolve_weights,
)
logger = logging.getLogger(__name__)
@ -1048,6 +1061,10 @@ def analyze_parcel(
str | None,
Query(description="user_id для fallback на default-профиль пользователя"),
] = None,
body: Annotated[
AnalyzeRequest | None,
Body(description="Опциональное тело запроса: inline POI-веса (#201)"),
] = None,
) -> dict[str, Any]:
"""Анализ участка: близость к социалке + district context + конкуренты.
@ -1223,15 +1240,41 @@ def analyze_parcel(
.all()
)
# 3b) Resolve effective POI weights (profile → user default → system)
from app.services.site_finder.weight_profiles import resolve_weights as _resolve_weights
# 3b) Resolve effective POI weights (inline → profile → user default → system)
_inline_weights: dict[str, float] | None = body.weights if body is not None else None
_effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id)
_weights_source = (
"profile"
if profile_id is not None
else ("user_default" if profile_user_id is not None else "system")
)
if _inline_weights is not None:
# Validate inline weights: keys и диапазон значений (#201)
bad_keys = set(_inline_weights.keys()) - _ALLOWED_CATEGORIES
if bad_keys:
raise HTTPException(
status_code=422,
detail=(
f"Неизвестные POI-категории: {sorted(bad_keys)}. "
f"Допустимые: {sorted(_ALLOWED_CATEGORIES)}"
),
)
out_of_range = {
k: v for k, v in _inline_weights.items() if v < _MIN_WEIGHT or v > _MAX_WEIGHT
}
if out_of_range:
raise HTTPException(
status_code=422,
detail=(
f"Веса за пределами допустимого диапазона "
f"[{_MIN_WEIGHT}, {_MAX_WEIGHT}]: {out_of_range}"
),
)
# Inline weights applied — merge поверх системных defaults (partial override)
_effective_weights = {**_POI_WEIGHTS, **_inline_weights}
_weights_source = "inline"
else:
_effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id)
_weights_source = (
"profile"
if profile_id is not None
else ("user_default" if profile_user_id is not None else "system")
)
# 4) Scoring: weighted sum с distance decay
score = 0.0
@ -1926,12 +1969,13 @@ def analyze_parcel(
nspd_engineering_nearby=nspd_dump_data["nspd_engineering_nearby"],
nspd_dump=nspd_dump_data["nspd_dump"],
),
# #114: кастомные веса POI — source + applied dict для прозрачности.
# #114/#201: кастомные веса POI — source + applied dict для прозрачности.
"weights_profile": {
"source": _weights_source,
"profile_id": profile_id,
"user_id": profile_user_id,
"weights_applied": _effective_weights,
"inline_weights": _inline_weights,
},
}

View file

@ -213,3 +213,24 @@ class BestLayoutsResponse(BaseModel):
top_layouts: list[TopLayoutRow]
recommendation_for_tz: LayoutTzRecommendation
data_quality: LayoutDataQuality
# ── Analyze endpoint inline weights (#201) ────────────────────────────────────
class AnalyzeRequest(BaseModel):
"""Опциональное тело запроса POST /analyze.
Позволяет передать inline POI-веса напрямую в запросе без сохранения
профиля. Если задан weights применяется с наивысшим приоритетом
(выше profile_id и user default).
"""
weights: dict[str, float] | None = Field(
default=None,
description=(
"Inline POI weights override (категория → weight). "
"Если задан — применяется к scoring, без обязательного profile save. "
"Validated против ALLOWED_CATEGORIES + MIN_WEIGHT/MAX_WEIGHT."
),
)

View file

@ -0,0 +1,302 @@
"""Тесты для inline POI-weights в POST /api/v1/parcels/{cad_num}/analyze (#201).
Покрывает:
1. POST /analyze без body system defaults (no regression)
2. POST /analyze с inline weights applied (source = "inline")
3. POST /analyze с невалидной категорией 422
4. POST /analyze с весом вне диапазона 422
5. POST /analyze с body.weights + profile_id body.weights wins (priority)
Стратегия mock: DB патчим через dependency_overrides, тяжёлые service-функции
(weather, velocity, dump и т.д.) патчим через unittest.mock.patch чтобы не
дублировать все 18 db.execute call'ов в каждом тесте.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.main import app
# ── Константы ─────────────────────────────────────────────────────────────────
_CAD = "66:41:0204016:10"
_WKT = "POLYGON((60.6 56.838, 60.61 56.838, 60.61 56.845, 60.6 56.845, 60.6 56.838))"
_GEOJSON = '{"type":"Polygon","coordinates":[[[60.6,56.838],[60.61,56.838]]]}'
# ── Mock factories ─────────────────────────────────────────────────────────────
def _make_mapping(data: dict[str, Any]) -> MagicMock:
"""Создать mock-строку (mapping) с __getitem__ + .get() для dict-like доступа."""
m = MagicMock()
m.__getitem__ = lambda self, k: data[k]
m.get = lambda k, default=None: data.get(k, default)
return m
def _make_db_for_analyze(
geom_found: bool = True,
district_found: bool = True,
poi_rows: list[Any] | None = None,
) -> MagicMock:
"""Сконструировать mock DB Session для analyze_parcel.
Порядок db.execute calls в analyze_parcel:
0. UNION ALL geom + source .mappings().first()
1. WKT query .mappings().first()
2. District .mappings().first()
3. POI rows .mappings().all()
4. Competitor rows .mappings().all()
5. Pipeline rows .mappings().all()
6. Centroid lat/lon .mappings().first()
7. Noise rows .mappings().all()
8. Hydrology .mappings().all()
9. Utilities .mappings().all()
10. Market trend .mappings().first()
11. Zoning (begin_nested) .mappings().first()
12. Success recommendation (begin_nested) .mappings().all()
13. _geotech_risk (industrial count) .scalar()
14. _neighbors_summary (neighbor_rows) .mappings().all()
15. _neighbors_summary (overlap_row) .mappings().first()
begin_nested() возвращаем context manager чтобы поддержать `with` statement.
"""
db = MagicMock()
geom_row = (
_make_mapping({"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"})
if geom_found
else None
)
wkt_row = _make_mapping({"wkt": _WKT}) if geom_found else None
district_row = (
_make_mapping(
{
"district_name": "Октябрьский",
"median_price_per_m2": 120000,
"dist_to_center": 1500.0,
}
)
if district_found
else None
)
centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})
_poi_rows = poi_rows or []
# Счётчик вызовов execute — разводим first() / all() / scalar() по очерёдности
call_idx = [0]
# Ответы в порядке вызовов:
responses: list[Any] = [
("first", geom_row), # 0: geom UNION ALL
("first", wkt_row), # 1: WKT
("first", district_row), # 2: district
("all", _poi_rows), # 3: POI rows
("all", []), # 4: competitor rows
("all", []), # 5: pipeline rows
("first", centroid_row), # 6: centroid
("all", []), # 7: noise rows
("all", []), # 8: hydrology rows
("all", []), # 9: utilities rows
("first", None), # 10: market trend
("first", None), # 11: zoning (inside begin_nested)
("all", []), # 12: success recommendation (inside begin_nested)
("scalar", 0), # 13: geotech_risk industrial count
("all", []), # 14: neighbors
("first", None), # 15: overlap
]
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
idx = call_idx[0]
call_idx[0] += 1
if idx >= len(responses):
# Безопасный fallback для непредусмотренных вызовов
r = MagicMock()
r.mappings.return_value.first.return_value = None
r.mappings.return_value.all.return_value = []
r.scalar.return_value = 0
return r
kind, data = responses[idx]
r = MagicMock()
r.mappings.return_value.first.return_value = data
r.mappings.return_value.all.return_value = data if isinstance(data, list) else []
r.scalar.return_value = data if kind == "scalar" else 0
return r
db.execute.side_effect = _execute_side_effect
# begin_nested() → context manager, остальные execute внутри него проходят
# через тот же side_effect (because db.execute is the same mock).
ctx = MagicMock()
ctx.__enter__ = MagicMock(return_value=ctx)
ctx.__exit__ = MagicMock(return_value=False)
db.begin_nested.return_value = ctx
return db
def _override_db(db: MagicMock):
def _get_db_override():
yield db
return _get_db_override
# Патчим тяжёлые внешние вызовы (weather / velocity / nspd-dump),
# чтобы тесты не зависели от сети и не требовали полного mock DB.
_PATCHES = [
patch("app.api.v1.parcels._fetch_air_quality_sync", return_value=None),
patch("app.api.v1.parcels._fetch_weather_sync", return_value=None),
patch("app.api.v1.parcels._fetch_seasonal_weather_sync", return_value=None),
patch(
"app.api.v1.parcels.get_quarter_dump_data",
return_value={
"nspd_zoning": None,
"nspd_zouit_overlaps": [],
"nspd_engineering_nearby": [],
"nspd_dump": {"available": False, "stale": False, "harvest_triggered": False},
},
),
patch("app.api.v1.parcels.compute_velocity", return_value=None),
patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}),
]
def _start_patches() -> list[Any]:
started = [p.start() for p in _PATCHES]
return started
def _stop_patches() -> None:
for p in _PATCHES:
p.stop()
# ── Тесты ─────────────────────────────────────────────────────────────────────
def test_analyze_no_body_uses_system_defaults() -> None:
"""POST /analyze без body → source = 'system', нет регрессии."""
from app.core.db import get_db
db = _make_db_for_analyze()
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
body = resp.json()
assert "weights_profile" in body
assert body["weights_profile"]["source"] == "system"
assert body["weights_profile"]["inline_weights"] is None
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_analyze_inline_weights_applied() -> None:
"""POST /analyze с body.weights → source = 'inline', веса применены."""
from app.core.db import get_db
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
resp = client.post(
f"/api/v1/parcels/{_CAD}/analyze",
json={"weights": {"kindergarten": 2.5}},
)
assert resp.status_code == 200, resp.text
body = resp.json()
wp = body["weights_profile"]
assert wp["source"] == "inline"
assert wp["inline_weights"] == {"kindergarten": 2.5}
# applied weights содержат inline override поверх defaults
assert wp["weights_applied"]["kindergarten"] == pytest.approx(2.5)
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_analyze_invalid_category_returns_422() -> None:
"""POST /analyze с невалидной POI-категорией → 422."""
from app.core.db import get_db
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
resp = client.post(
f"/api/v1/parcels/{_CAD}/analyze",
json={"weights": {"nonexistent_category": 1.0}},
)
assert resp.status_code == 422, resp.text
detail = resp.json()["detail"]
assert "nonexistent_category" in detail
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_analyze_weight_out_of_range_returns_422() -> None:
"""POST /analyze с весом вне [-2, 3] → 422."""
from app.core.db import get_db
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
# Слишком большой вес
resp = client.post(
f"/api/v1/parcels/{_CAD}/analyze",
json={"weights": {"school": 99.9}},
)
assert resp.status_code == 422, resp.text
detail = resp.json()["detail"]
assert "school" in detail
# Слишком маленький вес
resp2 = client.post(
f"/api/v1/parcels/{_CAD}/analyze",
json={"weights": {"park": -5.0}},
)
assert resp2.status_code == 422, resp2.text
assert "park" in resp2.json()["detail"]
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_analyze_inline_weights_beats_profile_id() -> None:
"""body.weights + profile_id → body.weights имеет приоритет (source = 'inline')."""
from app.core.db import get_db
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
# Передаём и profile_id=1, и inline weights — inline должен победить
resp = client.post(
f"/api/v1/parcels/{_CAD}/analyze?profile_id=1",
json={"weights": {"metro_stop": 2.0}},
)
assert resp.status_code == 200, resp.text
wp = resp.json()["weights_profile"]
assert wp["source"] == "inline", f"Ожидали source='inline', получили '{wp['source']}'"
assert wp["weights_applied"]["metro_stop"] == pytest.approx(2.0)
# profile_id всё ещё присутствует в ответе для трассировки
assert wp["profile_id"] == 1
finally:
app.dependency_overrides.clear()
_stop_patches()

View file

@ -147,14 +147,18 @@ function SiteFinderContent() {
function handleAnalyze(cadNum: string) {
setIsochrones(undefined);
setTab("overview");
// Priority: named profile → user default profile → inline draft weights.
// When activeProfileId is set, backend uses that profile (ignores inline).
// When no profile is selected, pass currentWeights as inline so draft
// slider values are always respected even without a saved profile (#201).
mutate({
cad: cadNum,
options:
activeProfileId != null
? { profileId: activeProfileId }
: profileUserId
? { profileUserId }
: undefined,
? { profileUserId, weights: currentWeights }
: { weights: currentWeights },
});
}
@ -163,10 +167,20 @@ function SiteFinderContent() {
profileId: number | null,
) {
setCurrentWeights(weights);
// Store the active profile id so we can pass it to the analyze call.
// If user edited weights without saving a named profile, profileId=null
// and backend will use system defaults (sub-PR 5 would enable inline weights).
setActiveProfileId(profileId);
// Re-analyze with the new weights if a parcel is already loaded (#201).
if (data?.cad_num) {
setIsochrones(undefined);
mutate({
cad: data.cad_num,
options:
profileId != null
? { profileId }
: profileUserId
? { profileUserId, weights }
: { weights },
});
}
}
// Derive KPI values from data

View file

@ -44,6 +44,11 @@ export interface AnalyzeOptions {
profileId?: number;
/** If set together with no profileId, backend uses user's default profile. */
profileUserId?: string;
/**
* Inline POI weights override sent as request body.
* Priority: inline profileId profileUserId default system.
*/
weights?: Record<string, number> | null;
}
/**
@ -90,11 +95,23 @@ export function useSiteAnalysis() {
return qsStr ? `${base}?${qsStr}` : base;
};
// Build optional JSON body for inline weights (#201).
const bodyPayload =
options?.weights != null
? JSON.stringify({ weights: options.weights })
: undefined;
// First request — POST /analyze
const first = await apiFetchWithStatus<
ParcelAnalysis | AnalyzeAcceptedResponse
>(analyzeUrl(cad), {
method: "POST",
...(bodyPayload
? {
body: bodyPayload,
headers: { "Content-Type": "application/json" },
}
: {}),
});
if (first.status === 200) {
@ -127,6 +144,12 @@ export function useSiteAnalysis() {
// mutation сразу резолвится с data — render skipped.
const second = await apiFetch<ParcelAnalysis>(analyzeUrl(cad), {
method: "POST",
...(bodyPayload
? {
body: bodyPayload,
headers: { "Content-Type": "application/json" },
}
: {}),
});
setFetchingState(null);
return second;