From 416bb4ed893d7740b84e608cb9a9703f4277923e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 21 Aug 2026 00:43:20 +0500 Subject: [PATCH] =?UTF-8?q?fix(ptica):=20=C2=AB=D0=B3=D0=B5=D0=BE=D0=BB?= =?UTF-8?q?=D0=BE=D0=B3=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B9=20=D1=80?= =?UTF-8?q?=D0=B8=D1=81=D0=BA=C2=BB=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D0=B2=D1=8B=D0=B2=D0=BE=D0=B4=D0=B8=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=B8=D0=B7=20=D1=88=D1=83=D0=BC=D0=B0=20(#2934?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- backend/app/api/v1/parcels.py | 31 ++--- .../api/v1/test_2934_no_fake_geology_label.py | 107 ++++++++++++++++++ 2 files changed, 125 insertions(+), 13 deletions(-) create mode 100644 backend/tests/api/v1/test_2934_no_fake_geology_label.py 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"поле где-то ещё ЧИТАЕТСЯ (не в комментарии): {попадания}" -- 2.45.3