fix(forecasting): нормализовать dict-district в build_site_finder_report (#1130 follow-up)
All checks were successful
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m26s
CI / backend-tests (pull_request) Successful in 6m22s
Deploy / build-backend (push) Successful in 1m45s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m42s
Deploy / deploy (push) Successful in 1m12s
All checks were successful
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m26s
CI / backend-tests (pull_request) Successful in 6m22s
Deploy / build-backend (push) Successful in 1m45s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m42s
Deploy / deploy (push) Successful in 1m12s
`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).
Добавлен `_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
This commit is contained in:
parent
2b23a4b24b
commit
31c5974c72
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