diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 25f718a0..f5cab072 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -42,6 +42,9 @@ 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, ) @@ -298,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": "Школа", @@ -1255,7 +1243,9 @@ def analyze_parcel( ), ) out_of_range = { - k: v for k, v in _inline_weights.items() if v < _MIN_WEIGHT or v > _MAX_WEIGHT + 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( 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 index d4fcdcc4..80a1a9d6 100644 --- a/backend/tests/api/v1/test_analyze_inline_weights.py +++ b/backend/tests/api/v1/test_analyze_inline_weights.py @@ -277,6 +277,31 @@ def test_analyze_weight_out_of_range_returns_422() -> None: _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