Когда 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
- #1736 MiniMap: проброс useConnectionPoints → точки подключения на карте analyze (были только в /legacy)
- #1737 confidence: пронесено имя сервиса → RU-ярлык (Рынок/Будущее предложение/…) вместо «Компонент вкладывающий сервис»
- #1738 pipeline: self-exclusion субъекта (ST_DWithin 80м) — проект не считает сам себя будущим конкурентом
- #1739 PDF: snapshot_pdf обёрнут в try/except+logger.exception (причина 500 видна) + format=pdf в forecast export + font_url fallback
- #1740 gate↔recommendation: при can_build_mkd=False — gate_caveat на обоих рекомендаторах (противоречие явное, не молчит)
Verify: py_compile 5/5, tsc 0, ruff clean, pytest confidence/forecast 95 passed.
Closes#1736Closes#1737Closes#1738Closes#1739Closes#1740
excel.py рендерил _scenario_deficit_index (скаляр) в таблицу сценариев под
шапкой «(12 мес)» — при fallback на другой горизонт подпись лгала (то же что
docx/pptx до 764db617). Добавлены _scenario_deficit_horizon и
_scenario_deficit_cell (зеркало report_pdf.py/764db617): при fallback ячейка
несёт «(гор. N мес)». _build_scenarios_sheet переключён на _scenario_deficit_cell.
Тесты: два новых кейса — fallback (6 мес → аннотация) и primary (12 мес → скаляр).
report_docx и report_pptx рендерили bare _scenario_deficit_index под
захардкоженной шапкой «(12 мес)» — при fallback на другой горизонт подпись
лгала. Переключил оба на _scenario_deficit_cell (как в report_md): per-cell
аннотация «(гор. N мес)» при N != 12. Закрывает code-review major по #1590.
#1244 (security): внешние/скрейпинг-строки (comm_name из DOM.РФ, headline/usp_text)
с ведущим = + - @ \t \r писались как есть → openpyxl сохранял как формулы
(data_type='f'), исполнялись при открытии в Excel/LibreOffice. _sanitize_formula
префиксует такие строки апострофом (OWASP CSV-injection escape); числа/даты/bool
не трогаются. _write_kv labels тоже санитизируются. Подтверждено на openpyxl 3.1.5.
#1245 (concurrency): async ask() вызывал sync get_report_for_chat() (sync SQLAlchemy
тянет крупный JSONB §22-отчёт) напрямую — блокировал event loop, в отличие от
LLM-ветки (run_in_threadpool). Обёрнуто в run_in_threadpool.
Closes#1244Closes#1245
render_report_docx (python-docx) mirrors report_md/report_pdf section order &
content, reuses report_pdf pure helpers (DRY), graceful on thin/empty report.
Widen /forecast/export format Literal to include docx → Word attachment.
Add python-docx dep + regenerate uv.lock (uv sync --frozen passes). Part of #959.
render_report_telegram_summary (pure, no new deps, DRY-reuses report_pdf str
helpers) + `tg` format on GET /{cad}/forecast/export → inline text/plain snippet
(no attachment, copy-paste-ready). md/json unchanged; no-run 404, bad format 422.
Graceful on thin/empty reports. Part of EPIC #959.
render_report_markdown (pure, no new runtime deps) reuses report_pdf's str
helpers (DRY), + GET /{cad_num}/forecast/export?format=md|json. No forecast run
→ 404; graceful on thin/empty reports; GFM-safe table escaping. PDF/XLSX already
existed; this adds the cheapest no-dep formats. Part of EPIC #959.
REOPENED. PDF + Excel exporters read non-existent dict keys, so demand/supply/
scenario columns silently rendered "—". Tests passed only because the fixtures
were stale (hand-typed the same wrong keys → fixture agreed with buggy exporter).
- future_market: demand/supply → projected_demand_units/projected_supply_units
- scenarios: drop non-existent per-scenario "overall"; show primary-horizon
deficit_index from ScenarioForecast.forecasts (scoring.overall was NOT broken)
- Excel #991: add missing future_supply (index + breakdown) + confidence.factors
sections; add future_supply to PDF for parity
- tests: rebuild forecast/scenario fixtures from real DemandSupplyForecast /
ScenarioForecast as_dict(); contract-key regression guards fail on key-drift
(verified: reintroducing old keys fails the new tests). 28 passed.
Refs #989#991
LIVE BUG (from #994, already merged): the analysis_runs persist call-site in
analyze_parcel extracted district via result_payload["district"]["district_name"]
— but a district dict without that key (real data: partial district lookup)
raised KeyError OUTSIDE the best-effort SAVEPOINT (extraction is at the call
site, not inside persist_analysis_run) → 500 on the LIVE /analyze endpoint.
Fix: .get("district_name") → None instead of raising. Caught by reviving the
analyze test suite (below).
Test-infra (the suite was un-runnable, which is why the bug shipped):
- Lazy-import WeasyPrint in layout_tz_pdf.py + trade_in_pdf.py (matches the
existing report_pdf.py / snapshot_pdf.py pattern). The eager top-level imports
made `app.main` (→ parcels/trade_in routers) un-importable on hosts without
WeasyPrint native libs (e.g. macOS dev), breaking pytest COLLECTION of the
whole api/v1 suite. WeasyPrint is still imported when a PDF is actually rendered.
- tests/conftest.py: autouse fixture clears app.dependency_overrides after each
test (anti-leak — a leaked get_db override caused real-DB connection attempts).
- test_parcel_by_bbox.py: rewrite get_db mocking from patch("...get_db") (a no-op
— FastAPI Depends holds the original ref) to app.dependency_overrides[get_db],
add explicit X-Authenticated-User header (RBAC gate), patch latest_run_dates,
+ a new test asserting last_analysis_date from a latest run (#994). 5/5 green.
NOTE: reviving collectability exposes PRE-EXISTING rot in other api/v1 suites
(analyze/admin/best_layouts: RBAC-header + stale-assertion/mock drift) — those
are NOT regressions from this PR (they were uncollectable before) and are
tracked separately for a deliberate suite-rehab + CI test-gate effort.
Refs #994.