diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 075983ab..f5cab072 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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, }, } diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 0a948e67..851904cd 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -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." + ), + ) diff --git a/backend/app/services/site_finder/weight_profiles.py b/backend/app/services/site_finder/weight_profiles.py index 7f3f0ad2..12259896 100644 --- a/backend/app/services/site_finder/weight_profiles.py +++ b/backend/app/services/site_finder/weight_profiles.py @@ -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 diff --git a/backend/tests/api/v1/test_analyze_inline_weights.py b/backend/tests/api/v1/test_analyze_inline_weights.py new file mode 100644 index 00000000..80a1a9d6 --- /dev/null +++ b/backend/tests/api/v1/test_analyze_inline_weights.py @@ -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() diff --git a/frontend/src/app/site-finder/page.tsx b/frontend/src/app/site-finder/page.tsx index faeddd61..5f914ca2 100644 --- a/frontend/src/app/site-finder/page.tsx +++ b/frontend/src/app/site-finder/page.tsx @@ -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 diff --git a/frontend/src/hooks/useSiteAnalysis.ts b/frontend/src/hooks/useSiteAnalysis.ts index 7a2c064c..7abd1a64 100644 --- a/frontend/src/hooks/useSiteAnalysis.ts +++ b/frontend/src/hooks/useSiteAnalysis.ts @@ -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 | 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(analyzeUrl(cad), { method: "POST", + ...(bodyPayload + ? { + body: bodyPayload, + headers: { "Content-Type": "application/json" }, + } + : {}), }); setFetchingState(null); return second;