feat(sf): включить enable_ird_analyze + latency-hardening (#1102, #1067)
All checks were successful
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 6s
CI / backend-tests (push) Successful in 6m23s
CI / backend-tests (pull_request) Successful in 6m20s

This commit is contained in:
bot-backend 2026-06-07 12:47:00 +03:00
parent a66a554462
commit 7fe39f2e68
8 changed files with 80 additions and 22 deletions

View file

@ -1379,12 +1379,8 @@ def export_parcel_forecast(
if format == "docx": if format == "docx":
return Response( return Response(
content=render_report_docx(run.result), content=render_report_docx(run.result),
media_type=( media_type=("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" headers={"Content-Disposition": f'attachment; filename="{base_name}.docx"'},
),
headers={
"Content-Disposition": f'attachment; filename="{base_name}.docx"'
},
) )
# pptx — байты (PowerPoint OOXML), отдельной веткой (зеркало docx early-return), # pptx — байты (PowerPoint OOXML), отдельной веткой (зеркало docx early-return),
@ -1395,9 +1391,7 @@ def export_parcel_forecast(
media_type=( media_type=(
"application/vnd.openxmlformats-officedocument.presentationml.presentation" "application/vnd.openxmlformats-officedocument.presentationml.presentation"
), ),
headers={ headers={"Content-Disposition": f'attachment; filename="{base_name}.pptx"'},
"Content-Disposition": f'attachment; filename="{base_name}.pptx"'
},
) )
if format == "json": if format == "json":
@ -3003,7 +2997,11 @@ def analyze_parcel(
db, geom_wkt, centroid_lon, centroid_lat db, geom_wkt, centroid_lon, centroid_lat
) )
except Exception: except Exception:
logger.warning("ird block failed for cad=%s — analyze response unaffected", cad_num) logger.warning(
"ird block failed for cad=%s — analyze response unaffected",
cad_num,
exc_info=True,
)
return result_payload return result_payload

View file

@ -135,11 +135,11 @@ class Settings(BaseSettings):
openrouteservice_api_key: str = "" openrouteservice_api_key: str = ""
# ИРД-слой в analyze (#1067 D9b «GG-форсайт»): поле `ird` в ответе analyze — # ИРД-слой в analyze (#1067 D9b «GG-форсайт»): поле `ird` в ответе analyze —
# parcel_ird_overlaps (м.132, incl opportunity) + функц.зона/КРТ (геопортал WFS) + # parcel_ird_overlaps (м.132, incl opportunity) + КРТ (геопортал WFS) +
# ПЗЗ-регламент зоны (C8b). Default OFF: источники 2-3 — live-зависимость от внешнего # ПЗЗ-регламент зоны (C8b). Включён 2026-06-07 после B6-harvest + прогрева
# ЕКБ-геопортала (reverse-eng, без SLA) в hot-пути analyze. Включать после прогона # zone_regulation_cache (#1102). Override через env ENABLE_IRD_ANALYZE=false остаётся
# harvest на проде (B6) и заполнения zone_regulation_cache. # возможен. functional_zone выключен — слой пуст в геопортале ЕКБ (0 фич, #1058).
enable_ird_analyze: bool = False enable_ird_analyze: bool = True
settings = Settings() settings = Settings()

View file

@ -33,7 +33,7 @@ from app.services.site_finder.zone_regulation import get_or_fetch_zone_regulatio
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Короткий timeout для geoportal-вызовов в hot-пути analyze (default клиента — 20s, слишком долго). # Короткий timeout для geoportal-вызовов в hot-пути analyze (default клиента — 20s, слишком долго).
_GEOPORTAL_TIMEOUT_S = 8 _GEOPORTAL_TIMEOUT_S = 4
def _functional_zone_at( def _functional_zone_at(
@ -105,10 +105,14 @@ def build_ird_analyze_block(
"krt": [], "krt": [],
"zone_regulation": None, "zone_regulation": None,
} }
# guard активен только в unit-тестах; в prod centroid_lon/lat всегда float
# (EKB-fallback в parcels.py).
# Geoportal-источники — только при валидном centroid (network, опц.). # Geoportal-источники — только при валидном centroid (network, опц.).
if lon is not None and lat is not None: if lon is not None and lat is not None:
client = client or EKBGeoportalClient(timeout=_GEOPORTAL_TIMEOUT_S) client = client or EKBGeoportalClient(timeout=_GEOPORTAL_TIMEOUT_S)
block["functional_zone"] = _functional_zone_at(client, lon, lat) # Слой генплан-функц.зон пуст в геопортале ЕКБ (0 фич, 2026-06); источник = ФГИС ТП (#1058).
# Не дёргаем geoportal впустую — вернём _functional_zone_at когда появится ingest.
block["functional_zone"] = None
block["krt"] = _krt_at(client, lon, lat) block["krt"] = _krt_at(client, lon, lat)
block["zone_regulation"] = _zone_regulation_at(db, client, lon, lat) block["zone_regulation"] = _zone_regulation_at(db, client, lon, lat)
return block return block

View file

@ -141,6 +141,19 @@ _PATCHES = [
), ),
patch("app.api.v1.parcels.compute_velocity", return_value=None), patch("app.api.v1.parcels.compute_velocity", return_value=None),
patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}), patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}),
# enable_ird_analyze=True by default — мокаем чтобы не делать сетевые вызовы в тестах
patch(
"app.api.v1.parcels.build_ird_analyze_block",
return_value={
"ird_overlaps": [],
"ird_by_kind": {},
"opportunity_overlaps": [],
"planning_projects": [],
"functional_zone": None,
"krt": [],
"zone_regulation": None,
},
),
] ]
@ -270,9 +283,9 @@ def test_inline_weights_rejects_nan() -> None:
content=raw_body, content=raw_body,
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
) )
assert resp.status_code == 422, ( assert (
f"Ожидали 422 для NaN-weight, получили {resp.status_code}: {resp.text}" resp.status_code == 422
) ), f"Ожидали 422 для NaN-weight, получили {resp.status_code}: {resp.text}"
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
_stop_patches() _stop_patches()

View file

@ -140,6 +140,19 @@ _PATCHES = [
), ),
patch("app.api.v1.parcels.compute_velocity", return_value=None), patch("app.api.v1.parcels.compute_velocity", return_value=None),
patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}), patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}),
# enable_ird_analyze=True by default — мокаем чтобы не делать сетевые вызовы в тестах
patch(
"app.api.v1.parcels.build_ird_analyze_block",
return_value={
"ird_overlaps": [],
"ird_by_kind": {},
"opportunity_overlaps": [],
"planning_projects": [],
"functional_zone": None,
"krt": [],
"zone_regulation": None,
},
),
] ]

View file

@ -147,6 +147,19 @@ _PATCHES = [
), ),
patch("app.api.v1.parcels.compute_velocity", return_value=None), patch("app.api.v1.parcels.compute_velocity", return_value=None),
patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}), patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}),
# enable_ird_analyze=True by default — мокаем чтобы не делать сетевые вызовы в тестах
patch(
"app.api.v1.parcels.build_ird_analyze_block",
return_value={
"ird_overlaps": [],
"ird_by_kind": {},
"opportunity_overlaps": [],
"planning_projects": [],
"functional_zone": None,
"krt": [],
"zone_regulation": None,
},
),
] ]

View file

@ -129,6 +129,19 @@ _PATCHES = [
), ),
patch("app.api.v1.parcels.compute_velocity", return_value=None), patch("app.api.v1.parcels.compute_velocity", return_value=None),
patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}), patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}),
# enable_ird_analyze=True by default — мокаем чтобы не делать сетевые вызовы в тестах
patch(
"app.api.v1.parcels.build_ird_analyze_block",
return_value={
"ird_overlaps": [],
"ird_by_kind": {},
"opportunity_overlaps": [],
"planning_projects": [],
"functional_zone": None,
"krt": [],
"zone_regulation": None,
},
),
] ]

View file

@ -50,7 +50,9 @@ def _patch_overlay(monkeypatch: Any, overlay: dict[str, Any]) -> None:
def test_full_block_assembly(monkeypatch: Any) -> None: def test_full_block_assembly(monkeypatch: Any) -> None:
"""Все источники → overlaps + split opportunity + функц.зона + КРТ + регламент.""" """Все источники → overlaps + split opportunity + КРТ + регламент.
functional_zone всегда None: слой пуст в геопортале ЕКБ (0 фич, 2026-06) WFS не дёргается.
"""
_patch_overlay(monkeypatch, _OVERLAY) _patch_overlay(monkeypatch, _OVERLAY)
monkeypatch.setattr( monkeypatch.setattr(
ird_analyze, ird_analyze,
@ -67,7 +69,9 @@ def test_full_block_assembly(monkeypatch: Any) -> None:
"opportunity_auction", "opportunity_auction",
"opportunity_krt", "opportunity_krt",
] ]
assert block["functional_zone"] == {"name": "Ж-зона"} # functional_zone WFS выключен (слой пуст в геопортале ЕКБ, #1058) — всегда None
assert block["functional_zone"] is None
assert "functional_zone" not in [layer for layer, _, _ in client.calls]
assert block["krt"] == [{"num_oks_krt": "КРТ-7"}] assert block["krt"] == [{"num_oks_krt": "КРТ-7"}]
assert block["zone_regulation"] == {"zone_index": "Ц-1"} assert block["zone_regulation"] == {"zone_index": "Ц-1"}
assert block["planning_projects"] == _PLANNING assert block["planning_projects"] == _PLANNING