fix(report-pdf): best-effort inline §22-форсайт на холодном участке (#2259)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 6s
CI / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m4s
CI / backend-tests (pull_request) Successful in 14m34s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 6s
CI / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m4s
CI / backend-tests (pull_request) Successful in 14m34s
Полный PDF-отчёт не-открытого участка выходил без §4-§6: форсайт-ран (schema "1.0") строится лениво GET /forecast-поллингом и на холодном участке отсутствует (стресс-свип: 66:41:0001001:30). build_full_report теперь при отсутствии рана считает форсайт синхронно прямым вызовом build_site_finder_report (мы уже в worker'е, ~20-30с) с теми же дефолтами, что Celery-таск (horizons=[6,12,18,24] = _horizons_for(12), district-fallback, persist schema "1.0" — контракт сверен построчно, потребители по params.source не фильтруют), и перечитывает ран. Best-effort: сбой → warning (не exception — GlitchTip), отчёт без §4-§6 как раньше. Отчёт самодостаточен (как §7-концепция). Таймлимит 600с не тронут — worst-case ~250с < 540с soft. Refs #2259
This commit is contained in:
parent
c4201f7d2a
commit
1180d8f8c7
2 changed files with 215 additions and 5 deletions
|
|
@ -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-таски форсайта,
|
||||
~20–30с) и перечитываем; всё ещё нет (сбой/тонкие данные) → 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 (~20–30с) и персистим тем же
|
||||
контрактом, чтобы последующий `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'е) и перечитываем — самодостаточность
|
||||
# отчёта важнее +20–30с (как §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
|
||||
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue