fix(forecasting): нормализовать dict-district в build_site_finder_report (#1130 follow-up) #1199

Merged
bot-backend merged 1 commit from fix/orchestrator-district-dict-guard into main 2026-06-13 04:38:08 +00:00
2 changed files with 78 additions and 0 deletions

View file

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

View file

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