fix(ptica): «геологический риск» больше не выводится из шума (#2934) #3006

Merged
bot-backend merged 1 commit from fix/2934-risks-coverage into main 2026-08-20 20:09:20 +00:00
2 changed files with 125 additions and 13 deletions

View file

@ -3156,11 +3156,23 @@ def analyze_parcel(
except Exception as e:
logger.warning("district_price_block query failed for %s: %s", cad_num, e)
# B5-6) Risk indicators — flood_zone из cad_risk_zones + noise_score + geology proxy (SF-B5)
# B5-6) Risk indicators — flood_zone + noise_score (SF-B5).
#
# `geology_risk_label` УБРАН (#2934). Он назывался геологическим риском, а
# вычислялся из подтопления и ШУМА: «high» при подтоплении, «medium» при шуме
# ≥65 дБ, иначе «low». Геологии в нём не было ни одного бита. При этом
# `cad_risk_zones` пуста (0 строк, писателя нет — см. #2934 п.6), поэтому
# подтопление приходило только из OSM-прокси «река ближе 200 м», и на
# тихом участке без реки поле всегда говорило «low» — зелёный вердикт,
# ни разу не подкреплённый проверкой геологии.
#
# Замена не нужна: соседний блок `geology` честно отдаёт
# `data_available: false`, когда данных нет. Потребителей у поля не было —
# ни фронт, ни экспортёры, ни §19-allowlist чата его не читали, а в схеме
# `risks: dict[str, Any]`, поэтому OpenAPI не меняется.
risks_block: dict[str, Any] = {
"flood_zone": False,
"noise_score": round(noise_score, 2),
"geology_risk_label": None,
}
try:
with db.begin_nested():
@ -3182,20 +3194,13 @@ def analyze_parcel(
.first()
)
_flood = bool(flood_row and int(flood_row["cnt"]) > 0)
# Geology proxy через hydrology flood_risk_flag (уже посчитан выше)
# OSM-прокси «река или канал ближе 200 м» (посчитан выше в hydrology).
# На сегодня это ЕДИНСТВЕННЫЙ работающий источник этого признака:
# cad_risk_zones пуста, поэтому _flood всегда False (#2934 п.6).
_geo_flood = hydrology.get("flood_risk_flag", False) if hydrology else False
_has_flood = _flood or _geo_flood
# geology_risk_label: high если flooding, medium если шум > 65дБ, иначе low
if _has_flood:
_geo_label: str | None = "high"
elif noise_db_max >= 65.0:
_geo_label = "medium"
else:
_geo_label = "low"
risks_block = {
"flood_zone": _has_flood,
"flood_zone": _flood or _geo_flood,
"noise_score": round(noise_score, 2),
"geology_risk_label": _geo_label,
}
except Exception as e:
logger.warning("risks_block query failed for %s: %s", cad_num, e)

View file

@ -0,0 +1,107 @@
"""«Геологический риск» не выводится из шума (#2934).
`risks.geology_risk_label` назывался геологическим риском, а вычислялся так:
high если подтопление
medium если шум 65 дБ
low иначе
Геологии в нём не было ни одного бита. При этом `cad_risk_zones` пуста (0 строк,
писателя нет), поэтому подтопление приходило только из OSM-прокси «река ближе
200 м» и на тихом участке без реки поле ВСЕГДА говорило «low». Зелёный вердикт,
ни разу не подкреплённый проверкой геологии.
Поле убрано, а не переименовано: соседний блок `geology` честно отдаёт
`data_available: false`, когда данных нет, и потребителей у метки не было ни
фронт, ни экспортёры, ни §19-allowlist чата её не читали.
Тесты смотрят на исходник эндпоинта: собрать полный ответ `/analyze` в юните
нельзя (нужны БД, OSM, НСПД), а утверждение проверяемо и так метка не должна
существовать, и шум не должен участвовать в риск-блоке.
"""
from __future__ import annotations
import inspect
import re
def _risks_block_source() -> str:
"""Кусок analyze-эндпоинта, собирающий risks_block."""
from app.api.v1 import parcels
src = inspect.getsource(parcels)
начало = src.index("risks_block: dict[str, Any]")
конец = src.index("# 10) Market trend", начало)
return src[начало:конец]
def test_geology_risk_label_is_gone() -> None:
"""Головной: метки нет в ответе.
На origin/main она есть и на тихом участке без реки всегда равна 'low'.
"""
блок = _risks_block_source()
assert '"geology_risk_label"' not in блок, f"метка всё ещё выдаётся:\n{блок[:400]}"
def test_noise_no_longer_feeds_the_risk_label() -> None:
"""Контроль подмены: шум не должен участвовать в риск-метке.
Ловит «починку» через переименование если метку назвать иначе, но по-прежнему
считать из noise_db_max, обман останется тем же.
"""
блок = _risks_block_source()
assert "noise_db_max" not in блок, f"шум по-прежнему участвует в риск-блоке:\n{блок[:400]}"
assert not re.search(
r'"(high|medium|low)"', блок
), f"в риск-блоке остались словесные градации риска:\n{блок[:400]}"
def test_noise_score_itself_is_preserved() -> None:
"""Контроль от переусердствования: сам noise_score остаётся — он измерен честно."""
блок = _risks_block_source()
assert '"noise_score"' in блок, "убран и измеренный noise_score — это перебор"
def test_flood_zone_still_reported() -> None:
"""Контроль: flood_zone остаётся в ответе (его судьба — отдельный пункт #2934)."""
блок = _risks_block_source()
assert '"flood_zone"' in блок
def test_no_consumer_reads_the_removed_field() -> None:
"""Контроль обоснования: поле действительно никем не читалось.
Утверждение «потребителей нет» проверяется, а не декларируется иначе
удаление поля из публичного ответа держалось бы на слове.
"""
from pathlib import Path
from app.api.v1 import parcels
корень = Path(inspect.getsourcefile(parcels)).resolve().parents[4]
попадания: list[str] = []
for каталог, маски in (
(корень / "backend" / "app", ("*.py",)),
(корень / "frontend" / "src", ("*.ts", "*.tsx")),
):
for маска in маски:
for f in каталог.rglob(маска):
if "__fixtures__" in str(f) or "api-types" in f.name:
continue
текст = f.read_text(encoding="utf-8", errors="ignore")
for n, строка in enumerate(текст.splitlines(), 1):
if "geology_risk_label" not in строка:
continue
# Комментарий, объясняющий удаление, — не потребитель. Проверка
# обязана отличать упоминание от использования, иначе краснеет
# на собственном объяснении правки.
голая = строка.lstrip()
комментарий = (
голая.startswith("#") or голая.startswith("//") or голая.startswith("*")
)
if комментарий:
continue
попадания.append(f"{f.relative_to(корень)}:{n}")
assert попадания == [], f"поле где-то ещё ЧИТАЕТСЯ (не в комментарии): {попадания}"