fix(report-pdf): best-effort inline §22-форсайт на холодном участке — отчёт самодостаточен (#2357)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m55s
Deploy / build-worker (push) Successful in 2m52s
Deploy / deploy (push) Successful in 1m37s

This commit is contained in:
bot-backend 2026-07-04 00:30:12 +00:00
parent 1e5fc13635
commit 6468bd6b37
2 changed files with 215 additions and 5 deletions

View file

@ -7,8 +7,10 @@ volume `/app/reports/` с метадата-строкой в `analysis_runs` (sc
ПОТОК (:func:`build_full_report`):
1. analyze-ран (`latest_run_for(..., schema_version=ANALYZE_SCHEMA_VERSION)`) нет
ValueError (отчёт без базового анализа бессмысленен).
2. forecast-ран (`latest_run_for(..., schema_version="1.0")`) нет Part B (§4§6)
деградирует «нет данных», отчёт всё равно валиден (передаём {} в part_b).
2. forecast-ран (`latest_run_for(..., schema_version="1.0")`) нет best-effort
СИНХРОННО считаем его тут же (`_ensure_forecast_run`, зеркало Celery-таски форсайта,
~2030с) и перечитываем; всё ещё нет (сбой/тонкие данные) Part B (§4§6) деградирует
«нет данных», отчёт всё равно валиден (передаём {} в part_b).
3. КЭШ-ключ = (analyze_run_id, forecast_run_id). Если метадата-ран `report-pdf-1.0` с
теми же id уже есть И файл на месте cache-hit, PDF не пере-рендерим.
4. connection-capacity (`get_connection_capacity`) best-effort, для §3-резервов.
@ -68,6 +70,12 @@ REPORT_SCHEMA_VERSION = "report-pdf-1.0"
# "forecast-1.0", а именно "1.0", это SiteFinderReport._SCHEMA_VERSION).
_FORECAST_SCHEMA_VERSION = "1.0"
# Горизонты best-effort синхронного форсайта (мес). Зеркало Celery-таски
# `forecast_site_finder_report(horizon=12)`: `_horizons_for(12)` = sorted({6,12,18,24}|{12})
# = [6,12,18,24] (forecast.py) = orchestrator._DEFAULT_HORIZONS. Держим тот же набор,
# чтобы «холодный» участок получил §4§6 идентичные ленивому GET /forecast-пути.
_FORECAST_HORIZONS: tuple[int, ...] = (6, 12, 18, 24)
# Верхняя граница длинной стороны карт-PNG (px) — печатный A4, 1400 достаточно для
# ~150 dpi на ширину колонки, но не раздувает PDF гигабайтными растрами.
_MAP_MAX_PX = 1400
@ -211,6 +219,82 @@ def _get_connection_capacity(db: Session, cad: str) -> dict[str, Any] | None:
return None
def _ensure_forecast_run(
db: Session,
cad: str,
analyze_row: Any,
analyze: dict[str, Any],
) -> None:
"""Best-effort синхронно посчитать §22-форсайт для «холодного» участка (#2259 gap).
§22-форсайт-ран ("1.0") существует ТОЛЬКО если пользователь открывал страницу участка
(§22 строится лениво GET /forecast-поллингом Celery-таской `forecast_site_finder_report`).
На холодном участке полный отчёт выходил БЕЗ §4§6. Здесь тот же compute+persist, что
делает Celery-таска, но СИНХРОННО и ПРЯМЫМ вызовом: мы УЖЕ внутри worker'а
(build_full_report_task), поэтому НЕ .delay считаем inline (~2030с) и персистим тем же
контрактом, чтобы последующий `latest_run_for("1.0")` в build_full_report поймал свежий ран
(его новый id корректно войдёт в кэш-ключ отчёта cache-miss §4§6 попадут в PDF).
Зеркало `forecast_site_finder_report(horizon=12)` (workers/tasks/forecast.py):
horizons = `_FORECAST_HORIZONS` (= `_horizons_for(12)` = [6,12,18,24]);
district = денорм-колонка рана fallback analyze["district"]["district_name"];
`build_site_finder_report(...)` `report.as_dict()` `persist_analysis_run(...,
schema_version=d["schema_version"], status="done", ...)`.
Best-effort: ЛЮБОЙ сбой/долгий compute logger.warning + return (НЕ exception форсайт
может честно не собраться на тонких данных, GlitchTip-шум не нужен). Тогда отчёт, как и
раньше, выйдет без §4§6 (part_b деградирует «нет данных»).
Args:
db: SQLAlchemy session (та же, что у build_full_report свою НЕ открываем).
cad: кадастровый номер участка.
analyze_row: Row analyze-рана (несёт денорм `district`).
analyze: persist-payload analyze-рана (district-fallback + competitors для сегмента).
"""
try:
# Lazy import — тяжёлый forecasting-стек не нужен на module-load (как concept-стек).
from app.services.forecasting.orchestrator import build_site_finder_report
district = analyze_row.district or (analyze.get("district") or {}).get("district_name")
logger.info(
"build_full_report: холодный участок cad=%s — best-effort синхронный §22-форсайт "
"(district=%s horizons=%s)",
cad,
district,
_FORECAST_HORIZONS,
)
report = build_site_finder_report(
db,
analyze=analyze,
cad_num=cad,
district=district,
horizons=_FORECAST_HORIZONS,
)
d = report.as_dict()
new_id = persist_analysis_run(
db,
cad_num=cad,
result=d,
params={"horizon": 12, "source": "full-report-inline-forecast"},
district=district,
confidence=(d.get("confidence") or {}).get("level"),
status="done",
schema_version=d["schema_version"], # "1.0" (SiteFinderReport._SCHEMA_VERSION)
created_by=None,
segment=(d.get("meta") or {}).get("segment"),
)
logger.info("build_full_report: §22-форсайт посчитан inline cad=%s run_id=%s", cad, new_id)
except Exception:
# Форсайт может честно не собраться (тонкие данные / сбой §9.x-шва) или занять
# слишком долго — деградируем в отчёт без §4§6 (warning, НЕ exception: держим
# GlitchTip-шум в узде, ведёт себя как ленивая Celery-таска, которая тоже best-effort).
logger.warning(
"build_full_report: inline §22-форсайт не собрался cad=%s → отчёт без §4§6",
cad,
exc_info=True,
)
def _find_cached_report(
db: Session,
cad: str,
@ -286,6 +370,14 @@ def build_full_report(db: Session, cad: str) -> dict[str, Any]:
analyze_run_id = int(analyze_row.id)
forecast_row = latest_run_for(db, cad, schema_version=_FORECAST_SCHEMA_VERSION)
if forecast_row is None:
# Холодный участок: §22-форсайт-ран ("1.0") строится лениво GET /forecast-поллингом
# и на не-открытом участке отсутствует → отчёт выходил без §4§6. Best-effort
# считаем его СИНХРОННО прямо тут (мы в worker'е) и перечитываем — самодостаточность
# отчёта важнее +2030с (как §7-концепция генерится в оркестраторе). Сбой/долго →
# forecast_row остаётся None, part_b деградирует «нет данных» (как раньше).
_ensure_forecast_run(db, cad, analyze_row, analyze)
forecast_row = latest_run_for(db, cad, schema_version=_FORECAST_SCHEMA_VERSION)
forecast: dict[str, Any] = (forecast_row.result or {}) if forecast_row is not None else {}
forecast_run_id = int(forecast_row.id) if forecast_row is not None else None

View file

@ -84,6 +84,9 @@ def _patch_renders(monkeypatch: pytest.MonkeyPatch, pdf_mock: MagicMock) -> None
# connection-capacity + концепция — заглушаем на None (best-effort, отдельные тесты ниже).
monkeypatch.setattr(mod, "_get_connection_capacity", lambda db, cad: None)
monkeypatch.setattr(mod, "_generate_concept_result", lambda db, analyze: None)
# inline §22-форсайт (best-effort synchronous compute) — по умолчанию no-op, чтобы
# тяжёлый orchestrator не тянулся; поведение «холодного» участка тестируем отдельно ниже.
monkeypatch.setattr(mod, "_ensure_forecast_run", lambda db, cad, row, analyze: None)
def test_no_analyze_run_raises(monkeypatch: pytest.MonkeyPatch) -> None:
@ -126,13 +129,55 @@ def test_happy_path_writes_file_and_persists(
assert kwargs["result"]["forecast_run_id"] == 202
def test_no_forecast_run_degrades(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Нет forecast-рана → forecast_run_id=None, Part B из пустого форсайта (не падает)."""
def test_no_forecast_run_triggers_inline_generation(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Холодный участок: forecast-рана нет → _ensure_forecast_run вызван → ран перечитан.
Генератор (mock) «персистит» §22-ран, поэтому повторный latest_run_for("1.0") ловит
свежий forecast_row его id попадает в отчёт и кэш-ключ (самодостаточность §4§6).
"""
analyze_row = _make_row(101, _analyze_result())
forecast_row = _make_row(777, _forecast_result()) # «появляется» после генерации
rows: dict[str | None, MagicMock | None] = {
"analyze-1.0": analyze_row,
"1.0": None, # первый read: рана ещё нет
mod.REPORT_SCHEMA_VERSION: None,
}
table = _RunTable(rows)
monkeypatch.setattr(mod, "latest_run_for", table)
monkeypatch.setattr(mod.settings, "reports_dir", str(tmp_path))
_patch_renders(monkeypatch, MagicMock(return_value=b"%PDF"))
monkeypatch.setattr(mod, "persist_analysis_run", MagicMock(return_value=1))
def _appear(db: Any, cad: str, row: Any, analyze: Any) -> None:
rows["1.0"] = forecast_row # генератор «персистит» § 22-ран
ensure_mock = MagicMock(side_effect=_appear)
monkeypatch.setattr(mod, "_ensure_forecast_run", ensure_mock)
result = mod.build_full_report(MagicMock(), _CAD)
# Генератор вызван ровно раз с cad + analyze-row (для district-fallback); ран перечитан.
ensure_mock.assert_called_once()
call_args, _ = ensure_mock.call_args
assert call_args[1] == _CAD # cad
assert call_args[2] is analyze_row # analyze_row
assert result["forecast_run_id"] == 777
assert result["analyze_run_id"] == 101
def test_inline_forecast_failure_degrades(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Inline-форсайт ничего не собрал (сбой/тонкие данные) → отчёт без §4§6.
_ensure_forecast_run best-effort (сам глотает исключение warning), поэтому здесь он
просто no-op: повторный latest_run_for("1.0") снова None forecast_run_id=None,
Part B из пустого форсайта (отчёт валиден, как до фикса на холодном участке)."""
analyze_row = _make_row(101, _analyze_result())
table = _RunTable({"analyze-1.0": analyze_row, "1.0": None, mod.REPORT_SCHEMA_VERSION: None})
monkeypatch.setattr(mod, "latest_run_for", table)
monkeypatch.setattr(mod.settings, "reports_dir", str(tmp_path))
_patch_renders(monkeypatch, MagicMock(return_value=b"%PDF"))
_patch_renders(monkeypatch, MagicMock(return_value=b"%PDF")) # ставит _ensure no-op
monkeypatch.setattr(mod, "persist_analysis_run", MagicMock(return_value=1))
result = mod.build_full_report(MagicMock(), _CAD)
@ -258,6 +303,79 @@ def test_connection_capacity_failure_is_best_effort(monkeypatch: pytest.MonkeyPa
assert mod._get_connection_capacity(MagicMock(), _CAD) is None
def test_ensure_forecast_run_computes_and_persists(monkeypatch: pytest.MonkeyPatch) -> None:
"""_ensure_forecast_run: считает §22 через orchestrator и персистит как Celery-таска.
Сверка с forecast_site_finder_report: build_site_finder_report(horizons=[6,12,18,24])
report.as_dict() persist_analysis_run(schema_version="1.0", status="done", ...).
"""
db = MagicMock()
analyze_row = _make_row(101, _analyze_result(), district="Кировский")
report = MagicMock()
report.as_dict.return_value = {
"schema_version": "1.0",
"confidence": {"level": "medium"},
"meta": {"segment": {"obj_class": "comfort"}},
}
build_mock = MagicMock(return_value=report)
persist_mock = MagicMock(return_value=888)
monkeypatch.setattr(
"app.services.forecasting.orchestrator.build_site_finder_report", build_mock
)
monkeypatch.setattr(mod, "persist_analysis_run", persist_mock)
mod._ensure_forecast_run(db, _CAD, analyze_row, _analyze_result())
# build_site_finder_report вызван с дефолт-горизонтами таски и district из денорм-колонки.
build_mock.assert_called_once()
_, bkwargs = build_mock.call_args
assert bkwargs["cad_num"] == _CAD
assert bkwargs["district"] == "Кировский"
assert list(bkwargs["horizons"]) == [6, 12, 18, 24]
# persist той же схемой "1.0" + confidence/segment из as_dict.
persist_mock.assert_called_once()
_, pkwargs = persist_mock.call_args
assert pkwargs["schema_version"] == "1.0"
assert pkwargs["status"] == "done"
assert pkwargs["confidence"] == "medium"
assert pkwargs["segment"] == {"obj_class": "comfort"}
def test_ensure_forecast_run_district_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
"""district=None в денорм-колонке → fallback на analyze['district']['district_name']."""
db = MagicMock()
analyze_row = _make_row(101, _analyze_result(), district=None)
analyze = {**_analyze_result(), "district": {"district_name": "Ленинский"}}
report = MagicMock()
report.as_dict.return_value = {"schema_version": "1.0"}
build_mock = MagicMock(return_value=report)
monkeypatch.setattr(
"app.services.forecasting.orchestrator.build_site_finder_report", build_mock
)
monkeypatch.setattr(mod, "persist_analysis_run", MagicMock(return_value=1))
mod._ensure_forecast_run(db, _CAD, analyze_row, analyze)
_, bkwargs = build_mock.call_args
assert bkwargs["district"] == "Ленинский"
def test_ensure_forecast_run_failure_is_best_effort(monkeypatch: pytest.MonkeyPatch) -> None:
"""orchestrator бросает → _ensure_forecast_run глотает (warning, НЕ пробрасывает)."""
db = MagicMock()
analyze_row = _make_row(101, _analyze_result())
monkeypatch.setattr(
"app.services.forecasting.orchestrator.build_site_finder_report",
MagicMock(side_effect=RuntimeError("§9.x seam failed")),
)
persist_mock = MagicMock()
monkeypatch.setattr(mod, "persist_analysis_run", persist_mock)
# Не должно бросить наружу — best-effort деградация в отчёт без §4§6.
mod._ensure_forecast_run(db, _CAD, analyze_row, _analyze_result())
persist_mock.assert_not_called() # compute упал ДО персиста
_MULTIPOLYGON = {
"type": "MultiPolygon",
"coordinates": [