Commit graph

2 commits

Author SHA1 Message Date
31c5974c72 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
`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
2026-06-13 09:24:55 +05:00
a0e61a38b4 feat(forecasting): §9.x→§22 orchestrator + fix supply-side district resolution (3a)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m37s
Deploy / build-worker (push) Successful in 2m25s
Deploy / deploy (push) Successful in 1m7s
Add build_site_finder_report (orchestrator.py): computes the §9.x layers (market
metrics, supply layers, future-supply pressure, demand/supply forecast, scenarios,
score card, special indices, recommendation overlay) with their heterogeneous
signatures and feeds the PURE assemble_report → §22 SiteFinderReport. Default segment
= modal competitor class; each §9.x call _safe_call-wrapped (graceful). Standalone —
no endpoint/Celery/persistence (that is 3b).

Prod ground-truth of the orchestrator surfaced a false-BUY bug: future_supply
(compute_future_supply_pressure) read the mixed-vocab persisted view
v_supply_layers_latest by a SCALAR admin district_name, missing all Layer-1
micro-keyed rows → admin parcel (Кировский) got supply=0 → false +1.0 deficit →
'Строить: недонасыщен' headline despite ~45k competing units. Fix: resolve
admin→micros, filter district_name = ANY(CAST(:names AS text[])) where names =
micros (L1) + admin (L2/L3), with :has_district EKB-wide guard (extends PR #1054's
resolver to the persisted-view path it missed). future_supply is the only
v_supply_layers_latest consumer on the forecast path (verified).

Prod after: Кировский supply 0→~42953, deficit +1.0→−1.0 (honest oversupply),
MOI 0→116.6, false-BUY headline gone, overall 0.734→0.42. 80 module tests pass
(signature-trap + resolver-regression guards genuine); ruff clean. Refs #961 #969.
2026-06-05 08:21:04 +05:00