diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index fe5325a8..9e6ce9df 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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) diff --git a/backend/tests/api/v1/test_2934_no_fake_geology_label.py b/backend/tests/api/v1/test_2934_no_fake_geology_label.py new file mode 100644 index 00000000..96f49925 --- /dev/null +++ b/backend/tests/api/v1/test_2934_no_fake_geology_label.py @@ -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"поле где-то ещё ЧИТАЕТСЯ (не в комментарии): {попадания}"