fix(ptica): «геологический риск» больше не выводится из шума (#2934)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-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 2m34s
CI / backend-tests (pull_request) Successful in 17m29s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-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 2m34s
CI / backend-tests (pull_request) Successful in 17m29s
`risks.geology_risk_label` назывался геологическим риском, а вычислялся так:
high — если подтопление
medium — если шум ≥ 65 дБ
low — иначе
Геологии в нём не было ни одного бита. При этом `cad_risk_zones` пуста
(0 строк, писателя нет — #2934 п.6), поэтому подтопление приходило только
из OSM-прокси «река ближе 200 м». На тихом участке без реки поле ВСЕГДА
говорило «low» — зелёный вердикт, ни разу не подкреплённый проверкой
геологии.
Поле убрано, а не переименовано: соседний блок `geology` честно отдаёт
`data_available: false`, когда данных нет. Замена не нужна.
Удаление поля из публичного ответа обосновано замером, а не словом:
потребителей нет ни в backend, ни во фронте, ни в §19-allowlist чата, ни
в экспортёрах; в схеме `risks: dict[str, Any]`, поэтому OpenAPI не
меняется. На это поставлен отдельный тест, который перечитывает дерево
исходников — иначе обоснование держалось бы на моём слове.
Двусторонне: против origin/main три теста красные («метка всё ещё
выдаётся», «шум по-прежнему участвует в риск-блоке», «поле где-то ещё
читается»). Контроли зелёные с обеих сторон: измеренный `noise_score`
остаётся на месте, `flood_zone` тоже (его судьба — отдельный пункт
задачи).
Контроль подмены отдельно: тест запрещает словесные градации риска в
блоке, иначе «починка» переименованием оставила бы тот же обман.
Проверка потребителей отличает комментарий от использования — иначе она
краснеет на собственном объяснении правки (на это я наступал трижды за
сутки, см. соседние PR).
pytest backend/tests/api/ — 377 passed, 1 skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
5cd3502493
commit
416bb4ed89
2 changed files with 125 additions and 13 deletions
|
|
@ -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)
|
||||
|
|
|
|||
107
backend/tests/api/v1/test_2934_no_fake_geology_label.py
Normal file
107
backend/tests/api/v1/test_2934_no_fake_geology_label.py
Normal 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"поле где-то ещё ЧИТАЕТСЯ (не в комментарии): {попадания}"
|
||||
Loading…
Add table
Reference in a new issue