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 текст, "не назван живой портал ГИСОГД-СО"