- 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,
|
make_empty_result,
|
||||||
)
|
)
|
||||||
from app.services.site_finder.velocity import compute_velocity
|
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 (
|
from app.services.site_finder.weight_profiles import (
|
||||||
ALLOWED_CATEGORIES as _ALLOWED_CATEGORIES,
|
ALLOWED_CATEGORIES as _ALLOWED_CATEGORIES,
|
||||||
)
|
)
|
||||||
|
|
@ -298,21 +301,6 @@ def _confidence_label(c: float) -> str:
|
||||||
return "low"
|
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).
|
# Человеко-читаемые имена категорий для verbal breakdown (X1).
|
||||||
_POI_CATEGORY_RU: dict[str, str] = {
|
_POI_CATEGORY_RU: dict[str, str] = {
|
||||||
"school": "Школа",
|
"school": "Школа",
|
||||||
|
|
@ -1255,7 +1243,9 @@ def analyze_parcel(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
out_of_range = {
|
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:
|
if out_of_range:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -25,7 +26,7 @@ from sqlalchemy import text
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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] = {
|
ALLOWED_CATEGORIES: set[str] = {
|
||||||
"school",
|
"school",
|
||||||
"kindergarten",
|
"kindergarten",
|
||||||
|
|
@ -44,7 +45,7 @@ ALLOWED_CATEGORIES: set[str] = {
|
||||||
MIN_WEIGHT: float = -2.0
|
MIN_WEIGHT: float = -2.0
|
||||||
MAX_WEIGHT: float = 3.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] = {
|
_SYSTEM_POI_WEIGHTS: dict[str, float] = {
|
||||||
"school": 1.5,
|
"school": 1.5,
|
||||||
"kindergarten": 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():
|
for k, w in v.items():
|
||||||
if not isinstance(w, int | float):
|
if not isinstance(w, int | float):
|
||||||
raise ValueError(f"Weight for '{k}' must be number, got {type(w).__name__}")
|
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}]")
|
raise ValueError(f"Weight for '{k}' = {w} out of bounds [{MIN_WEIGHT}, {MAX_WEIGHT}]")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -277,6 +277,31 @@ def test_analyze_weight_out_of_range_returns_422() -> None:
|
||||||
_stop_patches()
|
_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:
|
def test_analyze_inline_weights_beats_profile_id() -> None:
|
||||||
"""body.weights + profile_id → body.weights имеет приоритет (source = 'inline')."""
|
"""body.weights + profile_id → body.weights имеет приоритет (source = 'inline')."""
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue