feat(site-finder): inline POI weights pass-through в /analyze (#201 Phase 1) #206

Merged
lekss361 merged 4 commits from feat/201-inline-weights into main 2026-05-16 11:00:42 +00:00
3 changed files with 35 additions and 19 deletions
Showing only changes of commit cada96e874 - Show all commits

View file

@ -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(

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

@ -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