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
Collaborator

Summary

Defensive нормализация district в build_site_finder_report — защита от silent-degrade при сыром dict-входе.

Контекст (как нашли)

В Phase A замере #1130 (2026-06-12) мой первый тестовый скрипт скормил analyze["district"] напрямую как district=:

district = analyze.get("district")  # это DICT {district_name, dist_to_center, ...}
report = build_site_finder_report(db, analyze=analyze, cad_num=..., district=district)

Result: forecasts.n_horizons=0, scenarios.n=0 + 15 TypeError: unhashable type: 'dict' traceback'ов из _safe_call (compute_market_metrics(district=<dict>)forecast_request_cache.wrapper:121cache.get(cache_key, _MISS) где cache_key содержит dict).

Прод штатно защищён ровно одним местом — workers/tasks/forecast.py:123:

district = row.district or (analyze.get("district") or {}).get("district_name")

Но любой другой caller (тесты, расширения чата, ad-hoc эндпоинты) этой конвенции не знает → silent degrade без warning'а в проде.

Что делает PR

_normalize_district(district: Any) -> str | None — pure нормализация на входе оркестратора:

вход результат
str как есть
None None
dict с district_name: str (непустая) извлекается
dict без district_name / с пустой / dict_name=42 None + logger.warning
прочее (int, list, ...) None + logger.warning

Никаких изменений в downstream _build_site_finder_report_impl — только вход в build_site_finder_report.

Test plan

  • 7 unit-тестов в TestNormalizeDistrict (str / None / dict + 4 крайних случая).
  • pytest tests/services/forecasting/test_orchestrator.py → 27 passed (20 existing + 7 new).
  • ruff check + ruff format --check → clean.
  • Post-deploy: smoke — штатный flow через forecast.py:123 уже даёт str, поведение не меняется.

Closes

Чип task_4a4aa3bb (заведён 2026-06-12 после Phase A замера).

Refs #1130

## Summary Defensive нормализация `district` в `build_site_finder_report` — защита от silent-degrade при сыром dict-входе. ## Контекст (как нашли) В Phase A замере #1130 (2026-06-12) мой первый тестовый скрипт скормил `analyze["district"]` напрямую как `district=`: ```python district = analyze.get("district") # это DICT {district_name, dist_to_center, ...} report = build_site_finder_report(db, analyze=analyze, cad_num=..., district=district) ``` Result: `forecasts.n_horizons=0`, `scenarios.n=0` + 15 `TypeError: unhashable type: 'dict'` traceback'ов из `_safe_call` (`compute_market_metrics(district=<dict>)` → `forecast_request_cache.wrapper:121` → `cache.get(cache_key, _MISS)` где `cache_key` содержит dict). Прод штатно защищён ровно одним местом — `workers/tasks/forecast.py:123`: ```python district = row.district or (analyze.get("district") or {}).get("district_name") ``` Но любой другой caller (тесты, расширения чата, ad-hoc эндпоинты) этой конвенции не знает → silent degrade без warning'а в проде. ## Что делает PR `_normalize_district(district: Any) -> str | None` — pure нормализация на входе оркестратора: | вход | результат | |---|---| | `str` | как есть | | `None` | `None` | | `dict` с `district_name: str` (непустая) | извлекается | | `dict` без `district_name` / с пустой / `dict_name=42` | `None` + `logger.warning` | | прочее (`int`, `list`, ...) | `None` + `logger.warning` | Никаких изменений в downstream `_build_site_finder_report_impl` — только вход в `build_site_finder_report`. ## Test plan - [x] 7 unit-тестов в `TestNormalizeDistrict` (str / None / dict + 4 крайних случая). - [x] `pytest tests/services/forecasting/test_orchestrator.py` → 27 passed (20 existing + 7 new). - [x] `ruff check` + `ruff format --check` → clean. - [ ] Post-deploy: smoke — штатный flow через `forecast.py:123` уже даёт str, поведение не меняется. ## Closes Чип `task_4a4aa3bb` (заведён 2026-06-12 после Phase A замера). Refs #1130
bot-backend added 1 commit 2026-06-13 04:25:22 +00:00
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
31c5974c72
`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
bot-backend merged commit 31c5974c72 into main 2026-06-13 04:38:08 +00:00
bot-backend deleted branch fix/orchestrator-district-dict-guard 2026-06-13 04:38:08 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#1199
No description provided.