fix(ptica): число ЗОУИТ перестаёт подписываться как число типов (#2464)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 8s
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 2m21s
CI / backend-tests (pull_request) Successful in 17m16s

encumbrance.zouit_count приходит из parcels.py как len(zouit_rows) — число ЗАПИСЕЙ
cad_zouit, пересёкших участок. Типы лежат отдельно, в zouit_types, и показаны
строкой ниже. Оба экспортёра подписывали это число как «Кол-во типов ЗОУИТ».

Расхождение не редкое — померил по реальным отчётам (analysis_runs):

  разборов с ЗОУИТ                1637
  где записей != типов             717   (43.8%)
  среднее отношение              1.35x
  максимум по проду        7 записей при 3 типах

То есть почти в половине отчётов с ЗОУИТ читателю показывали число, которое на
треть больше того, что обещает подпись.

Пункт эпика предлагал обратное — заменить значение на len(zouit_types). Это
сделало бы фолбэк несогласованным с основным путём: у источника счётчик считает
записи, и таким он приходит в чат-контекст (chat/retrieval.py) тоже. Врёт
подпись, её и правлю.

Тест формулирует инвариант по смыслу, а не сверкой с выбранной строкой: ЕСЛИ
подпись обещает типы, показанное число обязано равняться числу типов. Такая
формулировка переживёт разумное переименование и не даст «починить» тест
подгонкой подписи. Проверяются оба формата — HTML и DOCX.

Против origin/main (5 записей, 2 типа):

  html: подпись «Кол-во типов ЗОУИТ» показывает 5, типов 2  → падает
  docx: то же самое                                        → падает
  число записей всё ещё в сводке     — контроль, зелёный с обеих сторон
  перечисление типов на месте        — контроль, зелёный с обеих сторон

Контроли не для симметрии: первый ловит «починку», которая выкинула бы счётчик
вместо переименования, второй — потерю перечисления типов.

Прогоны: tests/services/exporters + tests/services/chat — 302 passed rc=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-08-20 13:53:30 +05:00
parent 07b5e5e6da
commit be446482d0
3 changed files with 130 additions and 2 deletions

View file

@ -298,7 +298,13 @@ def _build_zouit(doc: _DocxDocument, result: dict[str, Any]) -> None:
summary_pairs: list[tuple[str, Any]] = [
("Есть ЗОУИТ", has_zouit),
("Кол-во типов ЗОУИТ", zouit_count),
# Подпись именно «Кол-во ЗОУИТ», а не «типов»: значение приходит из
# encumbrance.zouit_count, а там `len(zouit_rows)` — число ЗАПИСЕЙ cad_zouit,
# пересёкших участок (parcels.py). Типы лежат отдельно, в zouit_types, и
# показаны строкой ниже. Прежняя подпись «Кол-во типов ЗОУИТ» расходилась со
# значением в 717 разборах из 1637 с ЗОУИТ — 43.8%, в среднем завышая «типы»
# в 1.35 раза (#2464).
("Кол-во ЗОУИТ", zouit_count),
]
if zouit_types:
summary_pairs.append(("Типы", ", ".join(str(t) for t in zouit_types)))

View file

@ -538,7 +538,13 @@ def _build_zouit(result: dict[str, Any]) -> str:
summary_pairs: list[tuple[str, Any]] = [
("Есть ЗОУИТ", has_zouit),
("Кол-во типов ЗОУИТ", zouit_count),
# Подпись именно «Кол-во ЗОУИТ», а не «типов»: значение приходит из
# encumbrance.zouit_count, а там `len(zouit_rows)` — число ЗАПИСЕЙ cad_zouit,
# пересёкших участок (parcels.py). Типы лежат отдельно, в zouit_types, и
# показаны строкой ниже. Прежняя подпись «Кол-во типов ЗОУИТ» расходилась со
# значением в 717 разборах из 1637 с ЗОУИТ — 43.8%, в среднем завышая «типы»
# в 1.35 раза (#2464).
("Кол-во ЗОУИТ", zouit_count),
]
if zouit_types:
summary_pairs.append(("Типы", ", ".join(str(t) for t in zouit_types)))

View file

@ -0,0 +1,116 @@
"""Подпись к числу ЗОУИТ не должна обещать «типы», когда там записи (#2464).
`encumbrance.zouit_count` приходит из `parcels.py` как `len(zouit_rows)` число ЗАПИСЕЙ
`cad_zouit`, пересёкших участок. Типы лежат отдельно, в `zouit_types`. Оба экспортёра
подписывали это число как «Кол-во типов ЗОУИТ».
Расхождение не редкое: из 1637 разборов с ЗОУИТ в 717 (43.8 %) число записей не равно
числу типов, в среднем завышая «типы» в 1.35 раза (максимум по проду 7 записей при
3 типах).
Инвариант проверяется по смыслу, а не сверкой с выбранной строкой: **если подпись обещает
типы, показанное число обязано равняться числу типов**. Такая формулировка переживёт любое
разумное переименование и не даст «починить» тест подгонкой подписи.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import re
from typing import Any
import pytest
# Пять записей ЗОУИТ, но всего два различных типа — ровно тот случай, где подпись врала.
_OVERLAPS: list[dict[str, Any]] = [
{"type_zone": "Охранная зона ЛЭП", "name": "ВЛ-110 №1", "coverage_pct": 12.0},
{"type_zone": "Охранная зона ЛЭП", "name": "ВЛ-110 №2", "coverage_pct": 8.0},
{"type_zone": "Охранная зона ЛЭП", "name": "ВЛ-110 №3", "coverage_pct": 3.0},
{"type_zone": "Приаэродромная территория", "name": "ПАТ подзона 3", "coverage_pct": 100.0},
{"type_zone": "Приаэродромная территория", "name": "ПАТ подзона 4", "coverage_pct": 100.0},
]
_RESULT: dict[str, Any] = {
"encumbrance": {
"has_zouit": True,
"zouit_count": 5,
"zouit_types": ["Охранная зона ЛЭП", "Приаэродромная территория"],
},
"nspd_zouit_overlaps": _OVERLAPS,
}
_RECORDS = 5
_TYPES = 2
def _html_pairs() -> list[tuple[str, str]]:
"""KV-пары из HTML-блока ЗОУИТ: [(подпись, значение), …]."""
from app.services.exporters.full_report_html import _build_zouit
out = _build_zouit(dict(_RESULT))
return [
(re.sub(r"<[^>]+>", "", k).strip(), re.sub(r"<[^>]+>", "", v).strip())
for k, v in re.findall(r"<tr>\s*<th>(.*?)</th>\s*<td>(.*?)</td>\s*</tr>", out, re.S)
] or [
(re.sub(r"<[^>]+>", "", k).strip(), re.sub(r"<[^>]+>", "", v).strip())
for k, v in re.findall(
r"<tr>\s*<td[^>]*>(.*?)</td>\s*<td[^>]*>(.*?)</td>\s*</tr>", out, re.S
)
]
def _docx_pairs() -> list[tuple[str, str]]:
"""KV-пары из DOCX-блока ЗОУИТ."""
from docx import Document
from app.services.exporters.full_report_docx import _build_zouit
doc = Document()
_build_zouit(doc, dict(_RESULT))
pairs: list[tuple[str, str]] = []
for table in doc.tables:
for row in table.rows:
cells = [c.text.strip() for c in row.cells]
if len(cells) == 2:
pairs.append((cells[0], cells[1]))
return pairs
@pytest.mark.parametrize("pairs_fn,fmt", [(_html_pairs, "html"), (_docx_pairs, "docx")])
def test_label_promising_types_must_show_types(pairs_fn: Any, fmt: str) -> None:
"""Подпись со словом «тип» обязана нести число типов, а не записей."""
pairs = pairs_fn()
assert pairs, f"{fmt}: не разобрал ни одной KV-пары — тест ослеп"
for label, value in pairs:
if "тип" not in label.lower():
continue
digits = re.fullmatch(r"\d+", value.strip())
if not digits:
continue # строка «Типы: A, B» — это перечисление, а не счётчик
assert int(value) == _TYPES, (
f"{fmt}: подпись {label!r} обещает типы, а показывает {value}"
f"это число ЗАПИСЕЙ ({_RECORDS}), типов же {_TYPES}"
)
@pytest.mark.parametrize("pairs_fn,fmt", [(_html_pairs, "html"), (_docx_pairs, "docx")])
def test_record_count_is_still_shown(pairs_fn: Any, fmt: str) -> None:
"""Контроль: само число записей из отчёта не пропало — подпись правится, не значение."""
pairs = pairs_fn()
counters = [(k, v) for k, v in pairs if re.fullmatch(r"\d+", v.strip())]
assert any(
int(v) == _RECORDS for _, v in counters
), f"{fmt}: число ЗОУИТ-записей ({_RECORDS}) исчезло из сводки: {counters}"
@pytest.mark.parametrize("pairs_fn,fmt", [(_html_pairs, "html"), (_docx_pairs, "docx")])
def test_types_are_still_listed(pairs_fn: Any, fmt: str) -> None:
"""Контроль: перечисление типов на месте — читатель по-прежнему видит, какие они."""
pairs = pairs_fn()
joined = " ".join(f"{k} {v}" for k, v in pairs)
assert (
"Охранная зона ЛЭП" in joined and "Приаэродромная территория" in joined
), f"{fmt}: типы ЗОУИТ пропали из сводки"