Commit graph

4 commits

Author SHA1 Message Date
3a13bce468 fix(forecasting): savepoint db.execute failures to stop §22 session poisoning (#2464)
All checks were successful
CI / changes (pull_request) Successful in 11s
CI Trade-In / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 3m1s
CI / backend-tests (pull_request) Successful in 16m4s
One layer's caught DB error left the shared §22 report Session in Postgres's
"aborted transaction" state with no rollback, silently emptying every later
layer on the same report. Wrap each failure point in db.begin_nested()
(SAVEPOINT): orchestrator._safe_call, macro_series _query_key_rate_monthly /
_query_inflation_monthly / _query_mortgage_monthly (per indicator),
special_indices._run (per index), and sales_series _query_source_a /
_query_source_b — so a sibling builder's failure no longer cascades.

Also close the RELEASE-SAVEPOINT trap: inner helpers that swallowed a
db.execute failure without their own savepoint left the tx aborted, so the
outer builder's savepoint failed at RELEASE (illegal in an aborted tx) and
poisoning still cascaded. Add savepoints at the inner catch sites —
special_indices._query_parcel_centroid and district_resolver._admin_names /
resolve_objective_districts.

Refs #2464 (cluster A session-poisoning).
2026-07-07 17:50:17 +05:00
dcccebf2c2 fix(forecasting): honest scenarios collapse when β rate-sensitivity gate fails (audit #1871 P1)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Successful in 1m17s
CI / openapi-codegen-check (pull_request) Successful in 2m11s
CI / backend-tests (pull_request) Successful in 10m19s
Когда demand_normalization honesty-gate отбивает β rate-sensitivity
(`sensitivity.confidence=='low'`, ЕКБ-регрессия не проходит n≥30/R²≥0.1/slope<0),
все 393 прод-отчёта получают coefficient=1.0 для всех трёх сценариев
conservative/base/aggressive. По дизайну backend честно деградирует, но фронт
рисует 3 разноцветные карточки/линии одинаковых чисел без caveat.

Backend (scenarios.py, report.py, report_assembler.py, orchestrator.py):
- compute_scenarios теперь возвращает (list, collapsed: bool, reason: str|None)
- _detect_collapsed(): math.isclose(rel_tol=1e-9, abs_tol=1e-6) сравнивает
  projected_demand_units И deficit_index на всех горизонтах между
  conservative и aggressive — расхождение в любой метрике на любом h → False
- _COLLAPSE_REASON_LOW_BETA — единственный источник истины каноничного
  русского предложения
- ReportScenarios получает поля scenarios_collapsed + scenarios_collapse_reason
- demand_normalization.py НЕ ТРОНУТ — поведение корректно по контракту

Backend экспортёры (report_pdf.py, excel.py):
- PDF: при collapse — тёмная плашка-параграф вместо таблицы трёх столбцов
- Excel: ОДНА строка «base» + caveat-ячейка вместо трёх идентичных столбцов

Frontend (forecast.ts, ScenariosBlock, ScenarioCards, ScenarioCompareTable,
ForecastChart, Section6Forecast, ptica.module.css):
- Тип ReportScenarios расширен двумя optional-полями
- ScenariosBlock (light): headline-bar + одна grid-карточка base + caveat
- ScenarioCards (ptica dark): sub-component ScenarioBaseCard, одна карточка
  + collapse-note, ScenarioCompareTable return null
- ForecastChart: effectiveScenarios filter — одна линия viz-1 «Базовый»
  вместо трёх перекрывающихся
- Section6Forecast: caveat выше графика, читается ТОЛЬКО из
  scenarios_collapse_reason (источник истины — backend constant)

Тесты: +18 новых (TestMetricsEqual×6, TestDetectCollapsed×7,
TestComputeScenariosCollapseDetection×4 + 1 проверка graceful + corner).
102 forecasting passed, 88 cross-module passed.

Refs #1871
2026-06-22 19:06:22 +05:00
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