From 31c5974c72167425fcae51da47d747f939374337 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 13 Jun 2026 09:24:55 +0500 Subject: [PATCH] =?UTF-8?q?fix(forecasting):=20=D0=BD=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20dict-d?= =?UTF-8?q?istrict=20=D0=B2=20build=5Fsite=5Ffinder=5Freport=20(#1130=20fo?= =?UTF-8?q?llow-up)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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). Добавлен `_normalize_district(district)` — pure-нормализация на входе оркестратора: - str → как есть; - None → None; - dict с district_name (непустая строка) → извлекаем; - dict без district_name / с пустым / неподдерживаемый тип → None + logger.warning. 7 unit-тестов в test_orchestrator.py::TestNormalizeDistrict (все варианты входов). Не меняет поведение штатного caller'а (str → str), только защищает от случайных dict-callers. Discovered through: #1130 Phase A (мой первый тестовый скрипт со скормленным сырым `analyze["district"]` dict выдал forecasts.n_horizons=0 + 15 TypeError'ов в _safe_call). Закрывает чип task_4a4aa3bb. Refs #1130 --- .../app/services/forecasting/orchestrator.py | 39 +++++++++++++++++++ .../services/forecasting/test_orchestrator.py | 39 +++++++++++++++++++ 2 files changed, 78 insertions(+) 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