- math.isfinite() guard в out_of_range check (NaN bypass: NaN<MIN/MAX → False) - Same guard в weight_profiles._validate_weights_dict (pre-existing dup bug) - _POI_WEIGHTS теперь импорт из weight_profiles._SYSTEM_POI_WEIGHTS (SSOT) - Test test_inline_weights_rejects_nan (raw NaN payload → 422)
This commit is contained in:
parent
038e39e2ec
commit
cada96e874
3 changed files with 35 additions and 19 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue