feat(site-finder): inline POI weights pass-through в /analyze (#201 Phase 1) (#206)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m35s
Deploy / build-worker (push) Successful in 2m44s
Deploy / build-frontend (push) Successful in 2m9s
Deploy / deploy (push) Successful in 37s

This commit is contained in:
lekss361 2026-05-16 11:00:41 +00:00
parent 256909d28b
commit 092976f656
6 changed files with 453 additions and 33 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,21 @@ 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 (
_SYSTEM_POI_WEIGHTS as _POI_WEIGHTS,
)
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__)
@ -285,21 +301,6 @@ def _confidence_label(c: float) -> str:
return "low"
# Веса POI-категорий для scoring (Максим: трамвай = минус)
_POI_WEIGHTS: dict[str, float] = {
"school": 1.5,
"kindergarten": 1.5,
"pharmacy": 0.8,
"hospital": 0.6,
"shop_mall": 1.2,
"shop_supermarket": 1.0,
"shop_small": 0.5,
"park": 1.8,
"bus_stop": 0.3,
"metro_stop": 1.5,
"tram_stop": -0.5, # негативный вес — шум / вибрация
}
# Человеко-читаемые имена категорий для verbal breakdown (X1).
_POI_CATEGORY_RU: dict[str, str] = {
"school": "Школа",
@ -1048,6 +1049,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 +1228,43 @@ 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 not math.isfinite(v) or 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 +1959,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

@ -17,6 +17,7 @@ from __future__ import annotations
import json
import logging
import math
from datetime import datetime
from typing import Any
@ -25,7 +26,7 @@ from sqlalchemy import text
logger = logging.getLogger(__name__)
# Allowed POI categories — mirrors _POI_WEIGHTS keys in api/v1/parcels.py
# Allowed POI categories — single source of truth; imported by api/v1/parcels.py
ALLOWED_CATEGORIES: set[str] = {
"school",
"kindergarten",
@ -44,7 +45,7 @@ ALLOWED_CATEGORIES: set[str] = {
MIN_WEIGHT: float = -2.0
MAX_WEIGHT: float = 3.0
# System defaults — keep in sync with _POI_WEIGHTS in parcels.py
# System defaults — single source of truth; imported as _POI_WEIGHTS by api/v1/parcels.py
_SYSTEM_POI_WEIGHTS: dict[str, float] = {
"school": 1.5,
"kindergarten": 1.5,
@ -115,7 +116,7 @@ def _validate_weights_dict(v: dict[str, float]) -> dict[str, float]:
for k, w in v.items():
if not isinstance(w, int | float):
raise ValueError(f"Weight for '{k}' must be number, got {type(w).__name__}")
if w < MIN_WEIGHT or w > MAX_WEIGHT:
if not math.isfinite(w) or w < MIN_WEIGHT or w > MAX_WEIGHT:
raise ValueError(f"Weight for '{k}' = {w} out of bounds [{MIN_WEIGHT}, {MAX_WEIGHT}]")
return v

View file

@ -0,0 +1,327 @@
"""Тесты для 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_inline_weights_rejects_nan() -> None:
"""NaN weight должен вернуть 422, а не propagate в score."""
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)
# Отправляем raw JSON с NaN — httpx.Client не умеет encode float('nan'),
# поэтому используем content= с явным bytes-телом.
raw_body = b'{"weights": {"school": NaN}}'
resp = client.post(
f"/api/v1/parcels/{_CAD}/analyze",
content=raw_body,
headers={"Content-Type": "application/json"},
)
assert (
resp.status_code == 422
), f"Ожидали 422 для NaN-weight, получили {resp.status_code}: {resp.text}"
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;