fix(forecasting): нормализовать dict-district в build_site_finder_report (#1130 follow-up) #1199
2 changed files with 78 additions and 0 deletions
|
|
@ -201,6 +201,40 @@ def _macro_as_of(macro: Any) -> str | None:
|
|||
return str(month) if month is not None else None
|
||||
|
||||
|
||||
def _normalize_district(district: Any) -> str | None:
|
||||
"""Привести `district` к str|None — защита от silent-degrade при dict-входе. PURE.
|
||||
|
||||
`analyze["district"]` в этой кодовой базе — `dict` вида
|
||||
``{"district_name": "Верх-Исетский", "dist_to_center": 0.0, "median_price_per_m2": ...}``.
|
||||
Штатный caller (`workers/tasks/forecast.py:123`) явно извлекает `district_name`:
|
||||
`district = row.district or analyze["district"]["district_name"]`. Но новые callers
|
||||
(тесты, расширения чата, ad-hoc эндпоинты) легко передают сырой dict без знания
|
||||
этой конвенции — тогда внутри §9.x-слоёв `compute_market_metrics(district=<dict>)`
|
||||
падает с `TypeError: unhashable type: 'dict'` в `forecast_request_cache.wrapper`,
|
||||
`_safe_call` это проглатывает → секции `future_market.forecasts_by_horizon=[]` и
|
||||
`scenarios.by_scenario={}` тихо остаются пустыми (silent degrade, не 500).
|
||||
|
||||
Здесь — defensive normalization на входе оркестратора: dict → `district_name`,
|
||||
str → как есть, None → None, всё остальное → None (с warning'ом).
|
||||
"""
|
||||
if district is None or isinstance(district, str):
|
||||
return district
|
||||
if isinstance(district, dict):
|
||||
name = district.get("district_name")
|
||||
if isinstance(name, str) and name:
|
||||
return name
|
||||
logger.warning(
|
||||
"build_site_finder_report: district передан как dict без district_name (%r) — None",
|
||||
sorted(district.keys()),
|
||||
)
|
||||
return None
|
||||
logger.warning(
|
||||
"build_site_finder_report: district неподдерживаемого типа %s — None",
|
||||
type(district).__name__,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def build_site_finder_report(
|
||||
db: Session,
|
||||
*,
|
||||
|
|
@ -220,7 +254,12 @@ def build_site_finder_report(
|
|||
несжатому пути (детерминизм §16/§26 сохранён); ускорение — чисто за счёт устранения
|
||||
дублирующих roundtrip'ов, БЕЗ конкурентности. Кэш живёт ровно одну сборку (на выходе
|
||||
из `with` отбрасывается — никакой кросс-отчётной утечки).
|
||||
|
||||
DEFENSIVE (#1130 follow-up): `district` нормализуется на входе — если caller передал
|
||||
сырой dict из `analyze["district"]` (а не извлечённое `district_name`), достанем имя.
|
||||
Подробности — `_normalize_district`. PURE-нормализация, без изменения остального флоу.
|
||||
"""
|
||||
district = _normalize_district(district)
|
||||
with forecast_cache():
|
||||
return _build_site_finder_report_impl(
|
||||
db,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
|
|||
|
||||
from app.services.forecasting.orchestrator import (
|
||||
_modal_obj_class,
|
||||
_normalize_district,
|
||||
_primary_horizon,
|
||||
_summarize_supply_layers,
|
||||
build_site_finder_report,
|
||||
|
|
@ -330,3 +331,41 @@ class TestGracefulDegradation:
|
|||
assert captured[slot] is None, f"{slot} should degrade to None"
|
||||
# сегмент всё равно проброшен (он PURE, не от §9.x).
|
||||
assert captured["segment"]["obj_class"] == "Комфорт"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Defensive district normalization (#1130 follow-up — silent-degrade guard).
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNormalizeDistrict:
|
||||
def test_str_passes_through(self) -> None:
|
||||
assert _normalize_district("Ленинский") == "Ленинский"
|
||||
|
||||
def test_none_passes_through(self) -> None:
|
||||
assert _normalize_district(None) is None
|
||||
|
||||
def test_dict_with_district_name_extracted(self) -> None:
|
||||
d = {
|
||||
"district_name": "Верх-Исетский",
|
||||
"dist_to_center": 0.0,
|
||||
"median_price_per_m2": 139908.25,
|
||||
}
|
||||
assert _normalize_district(d) == "Верх-Исетский"
|
||||
|
||||
def test_dict_without_district_name_returns_none(self) -> None:
|
||||
# Защитный fallback: dict без ожидаемого поля → None + warning.
|
||||
# Не падаем (silent-degrade лучше чем TypeError в forecast_cache.wrapper).
|
||||
assert _normalize_district({"dist_to_center": 0.0}) is None
|
||||
|
||||
def test_dict_with_empty_district_name_returns_none(self) -> None:
|
||||
assert _normalize_district({"district_name": ""}) is None
|
||||
|
||||
def test_dict_with_non_string_district_name_returns_none(self) -> None:
|
||||
# «district_name»: 42 — не строка, защищаемся.
|
||||
assert _normalize_district({"district_name": 42}) is None
|
||||
|
||||
def test_unsupported_type_returns_none(self) -> None:
|
||||
# list / int / прочие неподдерживаемые типы → None + warning.
|
||||
assert _normalize_district([1, 2, 3]) is None
|
||||
assert _normalize_district(42) is None
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue