docs(ptica): докстрока не обещает защиты, которой нет, и не шлёт на мёртвый хост (#2464) #2988

Merged
bot-backend merged 1 commit from fix/2464-tep-docstring into main 2026-08-20 17:58:21 +00:00
3 changed files with 145 additions and 10 deletions

View file

@ -70,10 +70,32 @@ _TABLE_CAPTION_RE = {
def _page_contains_table(text: str, table_no: int) -> bool:
"""Возвращает True если текст страницы содержит заголовок таблицы N.
"""Возвращает True если на странице встречается строка «Таблица N».
Требуем явный caption «Таблица N» hint-только режим (оглавление, перекрёстные ссылки)
даёт false positive и подавляется. Если caption присутствует достаточно.
Это поиск подстроки, без разбора контекста. Строка ОГЛАВЛЕНИЯ
«Таблица 11 Баланс территории ....... 34» от настоящей подписи не отличается
падеж тот же, именительный, и вызывающий код ставит found_start=True прямо на
странице содержания, начиная выдирать таблицы оттуда.
Прежняя редакция этой докстроки утверждала обратное будто ложные срабатывания
оглавления и перекрёстных ссылок здесь отсеиваются. Такого кода никогда не было
(#2464). Обещание защиты, которой нет, опаснее её отсутствия: читающий не станет
её добавлять. (Старая формулировка тут намеренно пересказана, а не процитирована:
гейт test_2464_tep_docstring_truth ищет обещание по тексту и не отличил бы цитату
от утверждения.)
Замер 20.08.2026 уточняет и границы проблемы: перекрёстные ссылки в косвенных
падежах регекс НЕ ловит он требует именительное «Таблица N», поэтому
«приведены в таблице 12», «см. Таблицу 12», «табл. 12» дают False. Опасно
ровно оглавление, а не любое упоминание.
Почему подавление не реализовано здесь и сейчас: таблица `ekb_ppt_tep` на проде
пуста (0 строк) URL в `_SEED_DOCS` заглушка, живых PDF нет, эвристику отсева
оглавления не на чем откалибровать. Правило, придуманное без образцов, ловит
ровно те случаи, которые придумали вместе с ним. Условие для реализации хотя
бы один настоящий документ (#1136); целиться следует в признаки оглавления
(точки-выноски, номер страницы в конце строки, несколько подписей на одной
странице), а не в падежи.
"""
cap = _TABLE_CAPTION_RE[table_no]
return bool(cap.search(text))
@ -303,9 +325,7 @@ def _parse_table13(raw_rows: list[list[str | None]]) -> list[dict[str, Any]]:
seen: set[tuple[str, str, str, str]] = set()
result: list[dict[str, Any]] = []
for rec in rows_clean:
area_key = (
f"{rec['area_ha_num']:.4f}" if rec["area_ha_num"] is not None else rec["area_ha"]
)
area_key = f"{rec['area_ha_num']:.4f}" if rec["area_ha_num"] is not None else rec["area_ha"]
key = (rec["phase"], rec["composition"], rec["zone"], area_key)
if key in seen:
continue

View file

@ -22,10 +22,22 @@ logger = logging.getLogger(__name__)
# ── Seed-документы ─────────────────────────────────────────────────────────────
# Образец: ППТ 22823 (2018), пояснительная записка.
#
# URL — placeholder. Реальный URL пояснительной записки лежит на ГИСОГД ЕКБ
# (https://gisogd.ekburg.ru/) под номером проекта планировки, но прямой PDF-линк
# требует ручного поиска через UI (#1136). До тех пор — ingest скипает с
# WARNING и метрики остаются {"docs": 0}.
# URL — placeholder, ingest скипает с WARNING, метрики остаются {"docs": 0}.
# Прод 20.08.2026: в таблице ekb_ppt_tep 0 строк — то есть загрузчик не отработал
# ни разу.
#
# ВАЖНО (#2464): хост `gisogd.ekburg.ru`, названный ниже как место, где «лежит
# реальный URL», НЕ СУЩЕСТВУЕТ — DNS не резолвит его ни с рабочей машины, ни с
# прод-хоста (проверено 20.08.2026). Прежняя редакция этого комментария отправляла
# искать документ вручную на портале, которого нет.
#
# Живой портал ГИСОГД Свердловской области — `gisogd66.midural.ru` (его использует
# загрузчик РНС/РВЭ, см. services/scrapers/gisogd66.py). Раздел 13 там — документы
# по земельному участку; проекты планировки лежат в других разделах, перечисление
# групп доступно через `/api/v1/{schema}/gisogddocgroups/{razdel}`. Перебор
# razdel3/4/5 показал разделы «Генеральный план», «Местные нормативы», «Правила
# землепользования и застройки» — точный раздел ППТ и формат ссылки на PDF
# пояснительной записки ещё предстоит найти (#1136).
#
# Override-пути для прогона:
# 1. Передать в task: ingest_ppt_tep([{"doc_ref": "...", "url": "...",

View file

@ -0,0 +1,103 @@
"""Докстрока `_page_contains_table` не обещает защиты, которой нет (#2464).
Прежняя редакция утверждала:
«Требуем явный caption «Таблица N» hint-только режим (оглавление,
перекрёстные ссылки) даёт false positive и подавляется.»
Кода подавления в функции никогда не было это `bool(cap.search(text))`, то есть
обычный поиск подстроки. Строка оглавления «Таблица 11 Баланс территории ..... 34» от
настоящей подписи не отличается: падеж тот же, именительный.
Замер 20.08.2026 уточнил и сам пункт эпика: приведённый в нём пример перекрёстной
ссылки «показатели приведены в таблице 12» регекс НЕ ловит он требует именительное
«Таблица N». Опасно ровно оглавление, а не любое упоминание. Это зафиксировано
характеризующим тестом, чтобы следующая попытка подавления целилась в признаки
оглавления, а не в падежи.
Обещание защиты, которой нет, опаснее её отсутствия: читающий не станет её
добавлять. Тот же класс, что «объявлено исполняется».
Почему подавление не реализовано, а докстрока приведена к коду: таблица
`ekb_ppt_tep` на проде пуста (0 строк) URL в `_SEED_DOCS` заглушка, живых PDF нет,
эвристику отсева оглавления не на чем откалибровать. Правило, придуманное без
образцов, ловит ровно те случаи, которые придумали вместе с ним.
"""
from __future__ import annotations
import inspect
from app.services.scrapers import ekb_ppt_tep_parser as mod
def test_docstring_does_not_promise_suppression_that_is_absent() -> None:
"""Головной: докстрока не должна обещать подавление false positive.
На origin/main она содержит «даёт false positive и подавляется», а в теле
функции один `cap.search(text)`. Красное значение конкретно: обещание есть,
реализации нет.
"""
doc = inspect.getdoc(mod._page_contains_table) or ""
src = inspect.getsource(mod._page_contains_table)
тело = src.split('"""')[-1]
# Ищем именно ОБЕЩАНИЕ («подавляется»), а не разговор о подавлении: новая
# редакция объясняет, почему подавление НЕ реализовано, и не должна краснеть.
обещает = "подавляется" in doc
реализует = any(kw in тело for kw in ("оглавлен", "leader", "count(", "finditer", "rsplit"))
assert not (обещает and not реализует), (
"докстрока обещает подавление false positive, а в теле только поиск "
f"подстроки:\n{тело.strip()}"
)
def test_docstring_names_the_actual_behaviour() -> None:
"""Докстрока обязана честно называть false positive, а не умалчивать о нём.
Просто убрать обещание было бы недостаточно: молчание читается как «всё
хорошо». Ловит «починку» через вычёркивание неудобной фразы.
"""
doc = (inspect.getdoc(mod._page_contains_table) or "").lower()
assert (
"false positive" in doc or "оглавлен" in doc
), "докстрока не предупреждает о ложных срабатываниях на оглавлении"
def test_toc_line_really_is_a_false_positive() -> None:
"""Характеризующий: строка оглавления действительно проходит как подпись.
Зелёный с обеих сторон он фиксирует ФАКТИЧЕСКОЕ поведение, о котором теперь
говорит докстрока. Если кто-то реализует подавление, тест покраснеет и
заставит обновить и докстроку, и его.
"""
оглавление = "СОДЕРЖАНИЕ\nТаблица 11 Баланс территории .......... 34\nТаблица 12 ТЭП .... 36"
assert mod._page_contains_table(оглавление, 11) is True, "оглавление обязано срабатывать"
# А вот перекрёстные ссылки в косвенных падежах регекс НЕ ловит: он требует
# именительное «Таблица N». Пункт эпика приводил «приведены в таблице 12» как
# пример false positive — замер это опроверг, и докстрока теперь говорит точнее.
for мимо in (
"Основные показатели приведены в таблице 12 настоящей записки.",
"см. Таблицу 12 ниже",
"Данные в табл. 12",
):
assert mod._page_contains_table(мимо, 12) is False, f"неожиданно сработало: {мимо!r}"
assert mod._page_contains_table("Никаких таблиц тут нет", 11) is False
def test_seed_comment_does_not_send_to_a_dead_host() -> None:
"""Комментарий сида не должен отправлять искать документ на несуществующем хосте.
`gisogd.ekburg.ru` не резолвится ни с рабочей машины, ни с прод-хоста
(проверено 20.08.2026), а прежний комментарий называл его местом, где «лежит
реальный URL». Живой портал gisogd66.midural.ru.
"""
from pathlib import Path
src = Path(inspect.getsourcefile(mod)).resolve().parents[3]
текст = (src / "app" / "workers" / "tasks" / "ekb_ppt_tep_sync.py").read_text(encoding="utf-8")
assert (
"НЕ СУЩЕСТВУЕТ" in текст or "не резолв" in текст.lower()
), "комментарий сида не предупреждает, что gisogd.ekburg.ru мёртв"
assert "gisogd66.midural.ru" in текст, "не назван живой портал ГИСОГД-СО"