From 32671204999a883c4bc953e6ecbfa0a9ae698355 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 20 Aug 2026 22:36:23 +0500 Subject: [PATCH] =?UTF-8?q?docs(ptica):=20=D0=B4=D0=BE=D0=BA=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=D0=B0=20=D0=BD=D0=B5=20=D0=BE=D0=B1=D0=B5?= =?UTF-8?q?=D1=89=D0=B0=D0=B5=D1=82=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D1=8B?= =?UTF-8?q?,=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D0=BE=D0=B9=20=D0=BD=D0=B5?= =?UTF-8?q?=D1=82,=20=D0=B8=20=D0=BD=D0=B5=20=D1=88=D0=BB=D1=91=D1=82=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BC=D1=91=D1=80=D1=82=D0=B2=D1=8B=D0=B9=20?= =?UTF-8?q?=D1=85=D0=BE=D1=81=D1=82=20(#2464)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_page_contains_table` документировалась как «hint-только режим (оглавление, перекрёстные ссылки) даёт false positive и подавляется». Кода подавления в функции никогда не было — это `bool(cap.search(text))`. Обещание защиты, которой нет, опаснее её отсутствия: читающий не станет её добавлять. Замер уточнил и сам пункт эпика. Приведённый в нём пример перекрёстной ссылки «показатели приведены в таблице 12» регекс НЕ ловит: он требует именительное «Таблица N», поэтому «в таблице 12», «см. Таблицу 12», «табл. 12» дают False. Опасно ровно ОГЛАВЛЕНИЕ — там падеж тот же именительный, и «Таблица 11 Баланс территории ..... 34» неотличима от подписи. Это зафиксировано характеризующим тестом, чтобы следующая попытка подавления целилась в признаки оглавления (точки-выноски, номер страницы в конце), а не в падежи. Подавление здесь не реализовано сознательно: `ekb_ppt_tep` на проде пуста (0 строк), URL в `_SEED_DOCS` — заглушка, живых PDF нет. Эвристику отсева не на чем откалибровать, а правило, придуманное без образцов, ловит ровно те случаи, которые придумали вместе с ним. Отдельно исправлен комментарий сида: хост `gisogd.ekburg.ru`, названный там местом, «где лежит реальный URL», НЕ СУЩЕСТВУЕТ — DNS не резолвит его ни с рабочей машины, ни с прод-хоста (20.08.2026). Комментарий отправлял искать документ на портале, которого нет. Назван живой портал `gisogd66.midural.ru` и способ перечислить его разделы. Двусторонне: против origin/main два гейта красные — «докстрока обещает подавление, а в теле только поиск подстроки» и «комментарий сида не предупреждает, что хост мёртв». Характеризующие тесты зелёные с обеих сторон: они фиксируют фактическое поведение, а контроль test_docstring_names_the_actual_behaviour ловит «починку» через вычёркивание неудобной фразы. Гейт ищет обещание по слову, поэтому старая формулировка в докстроке пересказана, а не процитирована — иначе он не отличил бы цитату от утверждения; это оговорено прямо в тексте. Co-Authored-By: Claude Opus 5 --- .../services/scrapers/ekb_ppt_tep_parser.py | 32 +++++- backend/app/workers/tasks/ekb_ppt_tep_sync.py | 20 +++- .../scrapers/test_2464_tep_docstring_truth.py | 103 ++++++++++++++++++ 3 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 backend/tests/services/scrapers/test_2464_tep_docstring_truth.py diff --git a/backend/app/services/scrapers/ekb_ppt_tep_parser.py b/backend/app/services/scrapers/ekb_ppt_tep_parser.py index 5756a793..2bceb204 100644 --- a/backend/app/services/scrapers/ekb_ppt_tep_parser.py +++ b/backend/app/services/scrapers/ekb_ppt_tep_parser.py @@ -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 diff --git a/backend/app/workers/tasks/ekb_ppt_tep_sync.py b/backend/app/workers/tasks/ekb_ppt_tep_sync.py index 76d4341e..bb9901b2 100644 --- a/backend/app/workers/tasks/ekb_ppt_tep_sync.py +++ b/backend/app/workers/tasks/ekb_ppt_tep_sync.py @@ -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": "...", diff --git a/backend/tests/services/scrapers/test_2464_tep_docstring_truth.py b/backend/tests/services/scrapers/test_2464_tep_docstring_truth.py new file mode 100644 index 00000000..1394c84b --- /dev/null +++ b/backend/tests/services/scrapers/test_2464_tep_docstring_truth.py @@ -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 текст, "не назван живой портал ГИСОГД-СО"