gendesign/backend/app/workers/tasks/ekb_ppt_tep_sync.py
bot-backend 3267120499
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m10s
CI / backend-tests (pull_request) Successful in 17m21s
docs(ptica): докстрока не обещает защиты, которой нет, и не шлёт на мёртвый хост (#2464)
`_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 <noreply@anthropic.com>
2026-08-20 22:36:23 +05:00

170 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Celery task: ingest ТЭП из PDF пояснительных записок ППТ/ПМТ (#1085).
Fetch PDF → parse_ppt_tep → UPSERT в ekb_ppt_tep (ON CONFLICT по doc_ref).
SAVEPOINT per-doc: ошибка одного документа не откатывает всю транзакцию.
Beat НЕ регистрируется — обновление редкое, запуск ручной/через admin.
"""
from __future__ import annotations
import json
import logging
import httpx
from sqlalchemy import text
from app.core.db import SessionLocal
from app.services.scrapers.ekb_ppt_tep_parser import PptTep, parse_ppt_tep
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# ── Seed-документы ─────────────────────────────────────────────────────────────
# Образец: ППТ 22823 (2018), пояснительная записка.
#
# 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": "...",
# "project_type": "ppt"}]).
# 2. Подменить _SEED_DOCS в env-fixture для теста.
#
# Acceptance follow-up #1136: после уточнения URL — разовый прогон,
# verify {"docs": 1} + непустые zone_balance/tep/phasing.
_SEED_DOCS: list[dict] = [
{
"doc_ref": "ppt2018_22823",
# VERIFY URL: реальный документ на gisogd.ekburg.ru или екатеринбург.рф.
# placeholder начинается с «# VERIFY» — ingest пропускает с WARNING.
"url": "# VERIFY URL: https://gisogd.ekburg.ru/documents/ppt/22823/poyasnit_PPT.pdf",
"project_type": "ppt",
},
]
_UPSERT_SQL = text("""
INSERT INTO ekb_ppt_tep (
doc_ref, project_type,
zone_balance, tep, phasing, raw_tables,
source_pdf, fetched_at
)
VALUES (
:doc_ref, :project_type,
CAST(:zone_balance AS jsonb),
CAST(:tep AS jsonb),
CAST(:phasing AS jsonb),
CAST(:raw_tables AS jsonb),
:source_pdf, now()
)
ON CONFLICT (doc_ref) DO UPDATE SET
project_type = EXCLUDED.project_type,
zone_balance = EXCLUDED.zone_balance,
tep = EXCLUDED.tep,
phasing = EXCLUDED.phasing,
raw_tables = EXCLUDED.raw_tables,
source_pdf = EXCLUDED.source_pdf,
fetched_at = now()
""")
def _fetch_pdf(url: str) -> bytes:
"""Скачивает PDF по URL; verify=False из-за CA Минцифры (как ekburg_permits, #242)."""
with httpx.Client(verify=False, timeout=60.0, follow_redirects=True) as client:
resp = client.get(url, headers={"User-Agent": "GenDesign-SF/1.0 (+https://gendsgn.ru/)"})
resp.raise_for_status()
return resp.content
def _tep_to_params(doc_ref: str, project_type: str, source_pdf: str, tep: PptTep) -> dict:
return {
"doc_ref": doc_ref,
"project_type": project_type,
"zone_balance": json.dumps(tep.zone_balance, ensure_ascii=False),
"tep": json.dumps(tep.tep, ensure_ascii=False),
"phasing": json.dumps(tep.phasing, ensure_ascii=False),
"raw_tables": json.dumps(tep.raw_tables, ensure_ascii=False),
"source_pdf": source_pdf,
}
@celery_app.task(name="tasks.ekb_ppt_tep_sync.ingest_ppt_tep")
def ingest_ppt_tep(docs: list[dict] | None = None) -> dict[str, int]:
"""Fetch PDF + parse ТЭП + UPSERT в ekb_ppt_tep.
Args:
docs: список [{doc_ref, url, project_type}] или None → используется
_SEED_DOCS (1 seed-документ образца ppt2018_22823).
Returns:
{"docs": N} — число успешно обработанных документов.
"""
target_docs: list[dict] = docs if docs is not None else _SEED_DOCS
logger.info("ekb_ppt_tep_sync: start, docs=%d", len(target_docs))
processed = 0
failed = 0
db = SessionLocal()
try:
for doc in target_docs:
doc_ref = doc["doc_ref"]
url = doc["url"]
project_type = doc.get("project_type", "ppt")
# Пропускаем placeholder-URL (seed без реального источника)
if url.startswith("# VERIFY"):
logger.warning(
"ekb_ppt_tep_sync: doc_ref=%s — URL не задан (placeholder), пропускаем",
doc_ref,
)
continue
try:
logger.info("ekb_ppt_tep_sync: fetch %s (%s)", doc_ref, url)
pdf_bytes = _fetch_pdf(url)
tep = parse_ppt_tep(pdf_bytes)
params = _tep_to_params(doc_ref, project_type, url, tep)
with db.begin_nested():
db.execute(_UPSERT_SQL, params)
processed += 1
logger.info(
"ekb_ppt_tep_sync: upserted %s — zones=%d tep=%d phasing=%d",
doc_ref,
len(tep.zone_balance),
len(tep.tep),
len(tep.phasing),
)
except Exception as exc:
failed += 1
logger.warning(
"ekb_ppt_tep_sync: failed for doc_ref=%s: %s",
doc_ref,
exc,
)
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
logger.info("ekb_ppt_tep_sync: done — processed=%d failed=%d", processed, failed)
return {"docs": processed}