diff --git a/backend/app/services/forecasting/orchestrator.py b/backend/app/services/forecasting/orchestrator.py index 42e44b73..247179b2 100644 --- a/backend/app/services/forecasting/orchestrator.py +++ b/backend/app/services/forecasting/orchestrator.py @@ -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=)` + падает с `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, diff --git a/backend/tests/services/forecasting/test_orchestrator.py b/backend/tests/services/forecasting/test_orchestrator.py index a54356e8..b213f940 100644 --- a/backend/tests/services/forecasting/test_orchestrator.py +++ b/backend/tests/services/forecasting/test_orchestrator.py @@ -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