fix(ptica): act_date — дата акта об изъятии, а не первой попавшейся ссылки (#2464)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy / build-worker (push) Successful in 3m29s
Deploy / deploy (push) Successful in 1m35s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 9s

Часть эпика #2464.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-08-20 16:43:27 +00:00
commit 508f2d6090
2 changed files with 206 additions and 12 deletions

View file

@ -37,6 +37,33 @@ _RE_ACT_NUMBER = re.compile(
)
_RE_ACT_DATE = re.compile(r"от\s+(\d{2})\.(\d{2})\.(\d{4})")
# Слова, по которым дата опознаётся как дата САМОГО акта-основания, а не
# ссылки на другой документ (#2464). «Сообщение о планируемом изъятии»
# открывается списком оснований, где первой строкой почти всегда стоит
# «Решение Екатеринбургской городской Думы от 06.07.2004 № 60/1 «Об
# утверждении Генерального плана города»» — Генплан, а не акт об изъятии.
# Брать первую дату подряд означало ставить всем участкам дату Генплана.
#
# Действующее основание изъятия — постановление (Администрации города об
# утверждении проекта планировки/межевания либо Правительства области),
# поэтому дата принимается, только если слово стоит в предшествующем контексте.
# Ссылки-помехи в этих документах — «Решение … Думы» и «Приказ Министерства»,
# и ни одна из них слова «постановление» не содержит.
#
# Пробовал требовать ещё и «администраци»: на пяти прод-документах результат
# тот же 5 из 5, но правило ломает законный случай «Постановление № 509-ПП»
# (областное постановление без слова «администрация») — он уже закреплён
# тестом test_act_date_extracted_from_text. Взято более широкое условие:
# на живых данных оно не хуже, а лишнего не отсекает.
_ACT_CONTEXT_WORDS = ("постановлени",)
# Ширина окна контекста. OCR перемешивает колонки таблицы, и между словами
# «Постановление Администрации города» и «от DD.MM.YYYY» вклинивается текст
# соседней колонки («…Администрации города документами) Екатеринбурга от
# 19.04.2019…»), поэтому окно шире самой фразы (~50 символов). Откалибровано
# на пяти прод-документах land_reservation: 80, 120 и 160 дают одинаковые
# 5 из 5, выбрана середина.
_ACT_CONTEXT_WINDOW = 120
# Паттерн цели: «в целях…», «для …», «под строительство …» — best-effort.
_RE_PURPOSE = re.compile(
r"(?:для|в целях?|под)\s+([^.;,\n]{10,120})",
@ -207,7 +234,9 @@ def extract_izyatie_records(
# Реквизиты акта из заголовка или текста.
act_number = _extract_act_number(doc_title) or _extract_act_number(normalized)
act_date = _extract_act_date(doc_title) or _extract_act_date(normalized)
act_date = _extract_act_date(doc_title) or _extract_act_date(
normalized, require_act_context=True
)
purpose = _extract_purpose(doc_title) or _extract_purpose(normalized)
# Поиск кад-номеров.
@ -268,19 +297,35 @@ def _extract_act_number(text: str) -> str | None:
return re.sub(r"\s+", "", m.group(1))
def _extract_act_date(text: str) -> str | None:
"""Извлекает дату акта «от DD.MM.YYYY» → строка «YYYY-MM-DD» для SQL DATE."""
m = _RE_ACT_DATE.search(text)
if not m:
return None
day, month, year = m.group(1), m.group(2), m.group(3)
try:
# Валидируем диапазоны.
d, mo, y = int(day), int(month), int(year)
def _act_context_matches(text: str, pos: int) -> bool:
"""Стоит ли перед датой упоминание постановления — акта-основания."""
ctx = text[max(0, pos - _ACT_CONTEXT_WINDOW) : pos].lower()
return all(word in ctx for word in _ACT_CONTEXT_WORDS)
def _extract_act_date(text: str, *, require_act_context: bool = False) -> str | None:
"""Извлекает дату акта «от DD.MM.YYYY» → строка «YYYY-MM-DD» для SQL DATE.
require_act_context=True брать только дату, перед которой стоит
упоминание постановления (#2464). Нужен для ТЕЛА
документа, где первой датой почти всегда идёт ссылка на Генплан-2004.
Для заголовка не нужен: там ссылок на посторонние акты нет.
Если подходящей даты нет, возвращается None. Это сознательно: отсутствие
даты честнее, чем дата чужого документа по ней нельзя ни отфильтровать
актуальные изъятия, ни сверить срок.
"""
for m in _RE_ACT_DATE.finditer(text):
if require_act_context and not _act_context_matches(text, m.start()):
continue
day, month, year = m.group(1), m.group(2), m.group(3)
try:
# Валидируем диапазоны.
d, mo, y = int(day), int(month), int(year)
except ValueError:
continue
if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100:
return f"{y:04d}-{mo:02d}-{d:02d}"
except ValueError:
pass
return None

View file

@ -0,0 +1,149 @@
"""act_date — дата акта об изъятии, а не первой попавшейся ссылки (#2464).
`_extract_act_date` брал ПЕРВОЕ «от DD.MM.YYYY» во всём OCR-тексте. «Сообщение о
планируемом изъятии» открывается списком оснований, и первой строкой там почти
всегда стоит
«Решение Екатеринбургской городской Думы от 06.07.2004 60/1
«Об утверждении Генерального плана города»»
то есть Генплан, а не акт об изъятии. На проде это дало 11 строк из 27
(два РАЗНЫХ документа: развязка на Сибирском тракте и улица Энергостроителей)
с одной датой 2004-07-06 при проектах 2020 и 2022 годов.
Проверено не по догадке: все пять исходных PDF были загружены и распознаны тем
же трактом, что использует загрузчик (`ocr_pdf_text` в прод-контейнере). Тексты
цитат ниже дословно оттуда, включая перемешивание колонок OCR'ом.
Правило («постановлени» в 120 символах перед датой) откалибровано на всех пяти
документах сразу, а не на одном: окна 80/120/160 дают одинаковые 5 из 5.
Более узкий вариант (плюс «администраци») давал те же 5 из 5, но ломал законный
случай «Постановление 509-ПП» областной акт без слова «администрация»,
уже закреплённый тестом test_act_date_extracted_from_text. Взято широкое условие.
Патченный код прогнан по всем пяти распознанным текстам целиком: 27 записей
ровно столько же, сколько строк в land_reservation, из них 11 меняют дату
2004-07-06 на настоящую, 16 остаются прежними.
"""
from __future__ import annotations
from app.services.scrapers.izyatie_ocr import _extract_act_date, extract_izyatie_records
# Заголовок раздела — дословно с прода (basis_act в land_reservation).
ЗАГОЛОВОК = (
"Сообщение о планируемом изъятии земельных участков и объектов недвижимого "
"имущества для муниципальных нужд"
)
КАД = "66:41:0303004:22"
def атааписи(текст_документа: str) -> str | None:
"""act_date так, как её увидит land_reservation — через сквозной путь.
Зовём `extract_izyatie_records`, а не приватный хелпер: его сигнатура
одинакова на обеих сторонах, поэтому против origin/main тест краснеет
НЕВЕРНЫМ ЗНАЧЕНИЕМ (дата Генплана), а не TypeError из-за нового параметра.
"""
записи = extract_izyatie_records(f"{текст_документа}\n{КАД}", ЗАГОЛОВОК, "http://x/y")
assert записи, "кад-номер не разобран — тест смотрел бы не туда"
return записи[0]["act_date"]
# ── Дословные фрагменты OCR прод-документов ───────────────────────────────────
# 89adb28a… — развязка Базовый/Комсомольская/Сибирский тракт. На проде дал 2004-07-06.
ДОК_РАЗВЯЗКА = (
"Администрация города Екатеринбурга\n\n"
"— Решение Екатеринбургской городской Думы от\n06.07.2004 № 60/1 «Об утверждении\n"
"Генерального плана развития городского округа»;\n\n"
"— Приказ Министерства строительства и развития\nинфраструктуры Свердловской области от\n"
"30.12.2021 № 746-П;\n\n"
"— Постановление Администрации города\nЕкатеринбурга от 27.05.2022 № 1413\n"
"«Об утверждении проекта планировки и проекта межевания территории»"
)
# 9b9d9a99… — улица Энергостроителей. На проде тоже дал 2004-07-06.
ДОКНЕРГОСТРОИТЕЛЕЙ = (
"Администрация города Екатеринбурга\n\n"
"— Решение Екатеринбургской городской Думы от\n06.07.2004 № 60/1 «Об утверждении\n"
"Генерального плана развития городского округа»;\n\n"
"— Приказ Министерства строительства и развития\nинфраструктуры Свердловской области от\n"
"30.12.2021 № 746-П «О внесении изменений в Генеральный план на период до 2025 года»;\n\n"
"— Постановление Администрации города\nЕкатеринбурга от 12.02.2020 № 259 «Об утверждении\n"
"проекта планировки и проекта межевания территории»"
)
# bf2bb1bd… — OCR перемешал колонки таблицы: слова фразы идут вперемешку.
ДОКЕРЕМЕШАННЫЙ = (
"Постановление Администрации города утверждении проекта планировки\n"
"Екатеринбурга от 29.09.2015 № 2687 территории и проекта межевания\n"
"«Об утверждении» (в ред. Постановления Администрации города\n"
"Екатеринбурга от 14.07.2022 № 1995)"
)
# ccd00215… — то же перемешивание, но короче.
ДОКЕРЕМЕШАННЫЙ_2 = (
"Реквизиты документов (при наличии таких Постановление Администрации города\n"
"документами) Екатеринбурга от 19.04.2019 № 863 «Об\nутверждении проекта межевания территории»"
)
# 188a5c36… — первой же датой идёт нужное постановление.
ДОКРЯМОЙ = (
"Реквизиты документов (при наличии таких документами)\n"
"Постановление Администрации города Екатеринбурга от 24.06.2019 № 1504\n"
"«Об утверждении проекта планировки и проекта межевания территории»"
)
def test_genplan_citation_is_not_taken_as_act_date() -> None:
"""Головной: дата Генплана-2004 не должна становиться датой изъятия.
На origin/main возвращается '2004-07-06' дата чужого документа,
на 18 лет раньше настоящего основания.
"""
got = атааписи(ДОК_РАЗВЯЗКА)
assert got != "2004-07-06", "взята дата Решения Думы об утверждении Генерального плана"
assert got == "2022-05-27", f"ожидали дату постановления № 1413, получили {got!r}"
def test_second_document_with_same_genplan_citation() -> None:
"""Тот же дефект во втором документе — он и дал одинаковую дату у 11 строк."""
got = атааписи(ДОКНЕРГОСТРОИТЕЛЕЙ)
assert got == "2020-02-12", f"ожидали дату постановления № 259, получили {got!r}"
def test_ministry_order_is_not_taken_either() -> None:
"""Контроль: приказ Министерства между Генпланом и постановлением тоже не берётся."""
got = атааписи(ДОК_РАЗВЯЗКА)
assert got != "2021-12-30", "взята дата приказа Министерства строительства"
def test_ocr_column_scramble_still_resolves() -> None:
"""Контроль ширины окна: перемешанные OCR'ом колонки не должны ломать разбор.
Окно шире самой фразы (~50 символов) именно ради этих двух документов:
между «Постановление» и «от DD.MM.YYYY» вклинивается текст соседней колонки.
"""
assert атааписи(ДОКЕРЕМЕШАННЫЙ) == "2015-09-29"
assert атааписи(ДОКЕРЕМЕШАННЫЙ_2) == "2019-04-19"
def test_correct_documents_unchanged() -> None:
"""Контроль от переусердствования: там, где на проде было верно, ничего не меняется."""
assert атааписи(ДОКРЯМОЙ) == "2019-06-24"
def test_no_act_citation_gives_none_not_a_wrong_date() -> None:
"""Контроль: без основания — None, а не дата постороннего документа.
Отсутствие даты честнее: по чужой дате нельзя ни отфильтровать актуальные
изъятия, ни сверить срок, и она неотличима от настоящей.
"""
текст = "Решение Екатеринбургской городской Думы от 06.07.2004 № 60/1 «Об утверждении»"
assert атааписи(текст) is None
def test_title_path_does_not_require_context() -> None:
"""Контроль: у заголовка ссылок на чужие акты нет, там правило не применяется."""
assert _extract_act_date("Постановление от 27.05.2022 № 1413") == "2022-05-27"